diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 647c2eb2..5ed9df9a 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -33,3 +33,7 @@ jobs: - name: Run ESLint working-directory: frontend run: npx eslint . --max-warnings 0 + + - name: Check translation catalogs and runtime language + working-directory: frontend + run: node scripts/merge-i18n-fragments.mjs && pnpm check:i18n diff --git a/.github/workflows/pr-template-check.yml b/.github/workflows/pr-template-check.yml index e61365de..57510e61 100644 --- a/.github/workflows/pr-template-check.yml +++ b/.github/workflows/pr-template-check.yml @@ -25,7 +25,7 @@ jobs: // if any unchecked, fail the workflow if (unchecked.length > 0) { - core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`); + core.setFailed(`Complete all PR checklist items. Unchecked items:\n${unchecked.join('\n')}`); } else { console.log('all checklist items are checked.'); } diff --git a/.gitignore b/.gitignore index 3687ca98..1507928f 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ profile.cov # i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成) frontend/messages/zh-CN.json frontend/messages/en.json +frontend/messages/ru.json diff --git a/Makefile b/Makefile index ff5e78e3..929fe3f6 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ code-check: exit 1; \ fi golangci-lint run - cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0 + cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm check:i18n && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0 build-backend: @echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..." diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 6d4fbb06..f331480f 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -17,13 +17,23 @@ sidebar: false ## [Unreleased] +- 新增完整俄语界面资源及服务端展示文案,支持俄语浏览器语言识别、原生语言名称、日期与数字格式、日历及数量复数形式;补齐图表标题、表单字段和无障碍按钮文案的国际化,内置错误页及离线页跟随界面语言,保留用户自定义内容。 +- 选择非默认语言时,在静态页面首次绘制前等待对应语言资源就绪,避免刷新或完整跳转时短暂显示英文。 +- 修复本地化审查发现的通知收件人误判,保留历史系统目标并正确解析同名普通用户;恢复推送模板变量与富文本标签,补齐公开错误和限流响应翻译,修正俄语技术示例、数量及术语,并在首次渲染、系统指标和日历无障碍标签中应用当前语言。界面脚本加载失败时提供独立的同语言重新加载入口,避免无限空白页。 + ### 优化 -- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。 +- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成;分页控件支持中英文及俄语,新增注册和列表错误按界面语言展示。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。 ### 🛠 修复 +- 将界面默认语言调整为英文,保留已有语言偏好和中文语言资源。 +- 静态页面以英文预渲染,并在水合界面绘制前应用已保存的语言偏好,避免英文界面刷新时先显示中文再切换。 +- 将 Server、Agent、任务执行详情、安装脚本及浏览器调试日志的固定描述统一为英文;任务和通知元数据、接口错误、页面标题、表单校验与内置页面模板按中英文语言包展示,历史任务记录在读取时兼容翻译,不回写用户数据或上传统计键。 +- 增加语言包一致性与运行时中文文案检查,防止页面和终端重新混入未登记的中文固定文案。 +- 语言选择器固定显示各语言的原生名称,并提供全仓库非 ASCII 文本清单命令,按模块和用途区分语言资源、源码、文档与兼容标识。 +- 根据实际界面审校英文通知、任务与模板提示,修正文档拼接缺少空格、重复用词和 Webhook JSON 示例转义;系统配置及内置模板的默认元数据按语言展示,保留自定义内容与原始编辑值,并改善窄屏 WAF 标题和通知模板说明的换行。 - 修复 Agent 自动注册响应凭证字段不一致导致节点不断重复创建的问题;新增持久化注册重试凭证与退避,响应丢失、并发重试或配置写入失败后仍复用原节点,且不会覆盖管理员修改或恢复已更换的节点凭证。支持目录刷盘的平台会确认原子替换后的目录持久化,失败时阻止使用未确认的注册凭证;接口文档同步列出两种兼容凭证字段。 - SQLite 文件数据库启动时自动启用 WAL 模式,改善读写并发;数据库导出改用一致性快照,确保包含 WAL 中已提交的数据。 diff --git a/docs/design/log-language.md b/docs/design/log-language.md new file mode 100644 index 00000000..8925f4b8 --- /dev/null +++ b/docs/design/log-language.md @@ -0,0 +1,45 @@ +# 运行时语言与国际化 + +## 运维输出与界面文案 + +Server、Agent、后台任务、安装/卸载脚本以及浏览器 PoW 调试日志的固定文案使用英文。语言切换不会改变控制台日志或 OTel 异常信息,便于跨语言检索和排障。任务参数、文件名、用户配置和第三方错误保留原文,可能包含中文。 + +前端使用 `next-intl`,支持 `en`、`zh-CN` 和 `ru`,默认语言仍为 `en`。语言偏好按 `NEXT_LOCALE` Cookie、浏览器语言、默认语言的顺序解析,俄语区域变体(如 `ru-RU`)归一为 `ru`。文案维护在 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 生成主消息包,禁止手动修改生成的 `en.json`、`zh-CN.json` 和 `ru.json`。技术示例与 HTML 使用原始消息读取,避免把代码中的花括号当作 ICU 参数。 + +语言选择器始终从各语言自己的资源读取名称:`English`、`中文`、`Русский`,不随当前界面语言翻译语言名称。日期、数字和日历跟随界面语言;语言切换不改变既有时区。俄语数量文案使用 ICU 的 `one`、`few`、`many` 和 `other` 分支。 + +静态导出仍预渲染英文。`` 中的独立启动脚本在正文绘制前读取语言偏好;若与预渲染语言不同,则暂时隐藏正文,Provider 提交对应语言资源后再显示,避免刷新时闪现英文。脚本不依赖应用 bundle,损坏的 Cookie 会回退到浏览器语言。脚本加载出错或等待超过 10 秒时,显示同语言的独立恢复提示和重新加载按钮;应用恢复后隐藏提示并清理等待监听,避免无限空白页。格式化使用的语言在 Provider 更新上下文前同步,已有数据的首次本地化渲染也使用正确的日期和数字格式。 + +不在 React 渲染上下文中的浏览器代码(如网络异常和纯表单工具)通过 `frontend/i18n/runtime.ts` 调用同一套 `next-intl` 文案。消息在使用时解析语言,不在模块导入时固定语言。API 客户端发送当前界面语言的 `Accept-Language`。 + +## 服务端展示边界 + +`pkg/i18n/locales` 保留内置服务端文案的英文、中文和俄语版本,英文同时是运行时源码中的基准文案。展示接口依次采用 `NEXT_LOCALE` Cookie、`Accept-Language`,没有支持的语言时回退英文。 + +- 错误中间件先记录原始英文异常及 OTel 状态,再翻译 HTTP 响应中的 `error_msg`。登录流程使用的 `smtp_invalid:` 和 `need_email_code:` 控制前缀保持不变。 +- 任务类型、参数说明、执行记录和内置通知元数据使用请求内的副本进行翻译,避免修改共享注册表或持久化记录。 +- 默认定时任务名称可翻译;管理员自定义名称、通知接收目标和已保存的消息模板保留原文。 +- 通知接收目标只识别既有技术标识 `system`、`0` 和历史别名 `系统`;显示译文 `System`、`Система` 不会扩大保留目标集合,也不会把同名普通用户改投给管理员。 +- 新任务执行详情保存英文系统文案。历史中文文本通过已知的完整文案或格式模板在读取时展示为英文,不执行数据库回写。 + +格式模板只识别已登记的系统消息,并逐字保留捕获到的参数;时间戳也保留。未知消息原样返回。此兼容机制只用于系统拥有的展示字段,不能用来翻译任意用户数据,也不能通过比较翻译后的文本决定业务状态。任务重试和存储配置校验使用错误类型进行分类。 + +## 持久化兼容性 + +上传统计原有的六个中文分类名同时是数据库计数器键和 API 分类标识,不能当作标签直接改名。保留这些既有键,前端图表、图例和最大分类说明单独使用语言包标签;无需清空或重建现有统计。 + +历史 SQL 迁移、测试样例、中文项目文档以及用户已经保存的数据不属于待替换的运行时固定文案。离线页面和通知模板一旦由管理员选择、编辑并保存,就作为用户数据处理,不随界面语言切换而回写。 + +系统配置描述和内置邮件模板的名称、说明可能来自历史 seed。管理界面仅在配置键或内置模板键已登记、且文本与已知语言默认值完全一致时,使用 `builtin-metadata` 语言分片展示;未知键、自定义描述与非内置模板保持原文。编辑表单保留原始状态,单纯保存其他字段不会把展示翻译写回数据库;邮件主题和正文始终按已保存内容显示。内置源站错误页和离线页在选择模板或空值预览时使用当前语言,已保存的自定义 HTML 保持不变。 + +系统状态 API 的诊断时长和容量字符串保留英文及技术单位,界面在展示边界转换为当前语言;原始字符串继续用于既有内存计算。数据库计数采用当前 `next-intl` 上下文格式化。日历使用 DayPicker 扩展语言包,同时翻译日期与导航、下拉框、选中日期等无障碍名称。 + +## 校验与扩展 + +在仓库根目录运行 `node scripts/audit-non-english.mjs` 可生成完整的非 ASCII 文本清单,默认输出到被 Git 忽略的 `.codex-artifacts/non-english-audit/`。`occurrences.csv` 包含文件、行号、模块、分类和原文;JSON 另含字符位置、Unicode 码点、字符集及跳过的文件;`review-candidates.csv` 单列待审查源码。命令读取所有已跟踪和未被忽略的新文件,不按业务目录名称排除内容,也识别 Unicode 转义。 + +清单包含标点、表情、注释、文档、语言资源与历史迁移,不表示每个命中都需要替换。依赖、构建输出和私有环境文件不进入清单;二进制文件单独列出,不执行截图 OCR 或解包。生成的 Swagger 和中文文档站配置保留中文,既有上传分类键保留兼容性。字符集也不等同于语言,例如 Han 字符可能属于中文或日文。 + +`pnpm check:i18n` 校验三个语言包的键、标签和 `$` 变量一致性,并检查 Go、TypeScript/TSX 和安装脚本中的汉字、日文假名、韩文及西里尔字母运行时文本;注释、语言资源、测试样例、六个上传持久化键及通知历史目标别名有明确边界。该检查同时接入 `make code-check` 和现有前端 CI。俄语资源测试还验证命名参数、真实 rich-text 渲染、ICU 格式、复数边界、可复制的限速语法及 HTML 模板占位符,服务端测试验证格式动词、模板变量和枚举值。技术标识(如 `10r/s`、`30m`、`true`、`and`)不随语言翻译。 + +新增语言时,补齐前端分片、语言配置与 Provider 消息映射,以及服务端同名语言目录项,并验证格式占位符一致。新增系统消息仍以英文作为源文案,在相应语言资源中补齐译文。新增数据协议应使用稳定标识与参数,不再引入以中文显示文案充当持久化键的设计。 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 380a4873..b4eab735 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -128,6 +128,8 @@ SQLite 文件数据库在启动时自动启用 WAL 模式;内存数据库保 | `log.max_size` | - | 单个日志文件的最大空间(MB),超出自动切割轮转 | `100` | | `log.max_age` | - | 历史切割日志文件最大保留天数 | `30` | +Server、Agent、调度器、任务框架及存储维护等运行时日志的固定描述使用英文,便于跨语言排障和检索。界面语言切换不会改变终端日志和 OTel 异常;接口错误、任务执行详情与内置元数据在展示时按语言翻译,历史记录不回写。用户配置名称及第三方错误保留原文,可能包含中文。现有日志格式、级别、参数及输出渠道保持不变,详见 [运行时语言与国际化](../design/log-language.md)。 + ### 6. 异步任务 Worker 队列配置 (`worker:`) | 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 | | --- | --- | --- | --- | diff --git a/frontend/app/(docs)/docs/api/page.tsx b/frontend/app/(docs)/docs/api/page.tsx index 406d8052..fe0041f6 100644 --- a/frontend/app/(docs)/docs/api/page.tsx +++ b/frontend/app/(docs)/docs/api/page.tsx @@ -1,17 +1,23 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { LegalPageLayout } from '@/components/common/docs/legal-page-layout'; -import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api'; +import { + createApiSections, + DOCS_LAST_UPDATED, +} from '@/components/common/docs/api'; export default function ApiDocPage() { + const rt = useTranslations('runtime'); + return ( - OpenFlare 提供简单、强大的 API - 接口,支持多种编程语言和开发环境。通过标准化的 RESTful - 接口,您可以轻松地与后端服务进行集成与二次开发。 + {rt('openflare_provides_a_simple_powerful_api_for_ba400468')}

} /> diff --git a/frontend/app/(docs)/docs/how-to-use/page.tsx b/frontend/app/(docs)/docs/how-to-use/page.tsx index f4006098..69daed45 100644 --- a/frontend/app/(docs)/docs/how-to-use/page.tsx +++ b/frontend/app/(docs)/docs/how-to-use/page.tsx @@ -1,17 +1,21 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { LegalPageLayout } from '@/components/common/docs/legal-page-layout'; -import { howToUseSections } from '@/components/common/docs/how-to-use'; +import { createHowToUseSections } from '@/components/common/docs/how-to-use'; import { DOCS_LAST_UPDATED } from '@/components/common/docs/api'; export default function HowToUsePage() { + const rt = useTranslations('runtime'); + return ( - 欢迎使用 OpenFlare - 管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。 + {rt('welcome_to_the_openflare_management_platform_this_e8ecbe4d')}

} /> diff --git a/frontend/app/(docs)/docs/privacy-policy/page.tsx b/frontend/app/(docs)/docs/privacy-policy/page.tsx index 195ed2b8..35f1a627 100644 --- a/frontend/app/(docs)/docs/privacy-policy/page.tsx +++ b/frontend/app/(docs)/docs/privacy-policy/page.tsx @@ -1,18 +1,23 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { LegalPageLayout } from '@/components/common/docs/legal-page-layout'; -import { privacySections } from '@/components/common/docs/privacy'; +import { createPrivacySections } from '@/components/common/docs/privacy'; import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms'; export default function PrivacyPolicyPage() { + const rt = useTranslations('runtime'); + return ( - 我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息, + {rt('we_value_your_privacy_this_policy_explains_2c9e3c0b')}
- 以及您享有的相关权利。 + {rt('and_the_rights_you_have_over_it_4560abf2')} } /> diff --git a/frontend/app/(docs)/docs/terms-of-service/page.tsx b/frontend/app/(docs)/docs/terms-of-service/page.tsx index ecb24e91..35d77f36 100644 --- a/frontend/app/(docs)/docs/terms-of-service/page.tsx +++ b/frontend/app/(docs)/docs/terms-of-service/page.tsx @@ -1,16 +1,25 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { LegalPageLayout } from '@/components/common/docs/legal-page-layout'; import { TERMS_LAST_UPDATED, - termsSections, + createTermsSections, } from '@/components/common/docs/terms'; export default function TermsOfServicePage() { + const rt = useTranslations('runtime'); + return ( 为了保障您的合法权益,请您仔细阅读以下条款。} + sections={createTermsSections(rt)} + description={ + + {rt('please_read_the_following_terms_carefully_to_c46a6a61')} + + } /> ); } diff --git a/frontend/app/(main)/access-logs/components/access-log-utils.ts b/frontend/app/(main)/access-logs/components/access-log-utils.ts index 8f16333a..3dca4faf 100644 --- a/frontend/app/(main)/access-logs/components/access-log-utils.ts +++ b/frontend/app/(main)/access-logs/components/access-log-utils.ts @@ -1,3 +1,4 @@ +import { getFormattingLocale } from '@/i18n/format'; export type AccessLogTab = 'overview' | 'ips' | 'list'; export type SearchDraft = { @@ -66,7 +67,7 @@ export function parseSortValue(value: string) { } export function formatCompactNumber(value: number) { - return new Intl.NumberFormat('zh-CN', { + return new Intl.NumberFormat(getFormattingLocale(), { notation: value >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 1, }).format(value); diff --git a/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx b/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx index 1ac6fd9d..cd0d3661 100644 --- a/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx +++ b/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx @@ -368,6 +368,8 @@ export function IpAnalysisPanel({ */ initialHours?: number; }) { + const uiText = useTranslations('runtime'); + const t = useTranslations('accessLogs'); const trendChartConfig = useTrendChartConfig(); const [ipGroupOpen, setIpGroupOpen] = useState(false); @@ -576,32 +578,32 @@ export function IpAnalysisPanel({ {analysis ? (
diff --git a/frontend/app/(main)/access-logs/components/overview-tab.tsx b/frontend/app/(main)/access-logs/components/overview-tab.tsx index 47ba8a0e..5a419eb7 100644 --- a/frontend/app/(main)/access-logs/components/overview-tab.tsx +++ b/frontend/app/(main)/access-logs/components/overview-tab.tsx @@ -318,6 +318,8 @@ function OverviewContent({ data: AccessLogOverview; hours: number; }) { + const uiText = useTranslations('runtime'); + const t = useTranslations('accessLogs'); const requestLabels = data.trends.requests.map((point) => formatOverviewTrendLabel(point.bucket_started_at, hours), @@ -331,7 +333,7 @@ function OverviewContent({ <>
- Requests over time + {uiText('ui_requestsOverTime')} {t('overview.requestsHint')} @@ -389,13 +391,13 @@ function OverviewContent({
@@ -422,17 +424,17 @@ function OverviewContent({
diff --git a/frontend/app/(main)/admin/database/components/cache-manager.tsx b/frontend/app/(main)/admin/database/components/cache-manager.tsx index ccbd776e..be76f5a6 100644 --- a/frontend/app/(main)/admin/database/components/cache-manager.tsx +++ b/frontend/app/(main)/admin/database/components/cache-manager.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useCallback, useEffect, useState } from 'react'; -import { useTranslations } from 'next-intl'; +import { useFormatter, useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { HardDrive, RefreshCw, Trash2 } from 'lucide-react'; @@ -28,25 +28,7 @@ import { } from '@/components/ui/dialog'; import type { CacheStatus } from '@/lib/services/admin'; import services from '@/lib/services'; - -/** - * 格式化字节大小 - */ -const formatBytes = (bytes: number) => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -}; - -/** - * 格式化数字,每3位加逗号 - */ -const formatNumber = (num: number | string) => { - if (num === undefined || num === null) return '0'; - return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ','); -}; +import { formatBytes } from '@/lib/utils/metrics'; interface CacheManagerProps { refreshTrigger: number; @@ -54,6 +36,7 @@ interface CacheManagerProps { export function CacheManager({ refreshTrigger }: CacheManagerProps) { const t = useTranslations('admin.database.cache'); + const format = useFormatter(); const [cacheStatus, setCacheStatus] = useState(null); const [loadingCache, setLoadingCache] = useState(true); const [savingConfig, setSavingConfig] = useState(false); @@ -208,7 +191,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { {t('cacheKeysCount')}

- {formatNumber(cacheStatus.keys_count)}{' '} + {format.number(cacheStatus.keys_count)}{' '} {t('files')} diff --git a/frontend/app/(main)/admin/database/components/sql-console.tsx b/frontend/app/(main)/admin/database/components/sql-console.tsx index 3ddd5150..4653b264 100644 --- a/frontend/app/(main)/admin/database/components/sql-console.tsx +++ b/frontend/app/(main)/admin/database/components/sql-console.tsx @@ -142,7 +142,9 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {

- {dbType === 'postgres' ? 'PostgreSQL Connected' : 'SQLite Connected'} + {t('connected', { + database: dbType === 'postgres' ? 'PostgreSQL' : 'SQLite', + })}
diff --git a/frontend/app/(main)/admin/database/components/table-browser.tsx b/frontend/app/(main)/admin/database/components/table-browser.tsx index 795b6d59..bfa08ef4 100644 --- a/frontend/app/(main)/admin/database/components/table-browser.tsx +++ b/frontend/app/(main)/admin/database/components/table-browser.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useCallback, useEffect, useState } from 'react'; -import { useTranslations } from 'next-intl'; +import { useFormatter, useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { Layers, RefreshCw } from 'lucide-react'; @@ -33,14 +33,6 @@ import { Button } from '@/components/ui/button'; import type { TableDataResponse } from '@/lib/services/db-manage'; import { DbManageService } from '@/lib/services/db-manage'; -/** - * 格式化数字,每3位加逗号 - */ -const formatNumber = (num: number | string) => { - if (num === undefined || num === null) return '0'; - return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ','); -}; - /** * 格式化表格单元格内容 */ @@ -69,6 +61,7 @@ export function TableBrowser({ refreshTrigger, }: TableBrowserProps) { const t = useTranslations('admin.database.tableBrowser'); + const format = useFormatter(); const [selectedTable, setSelectedTable] = useState(''); const [tableData, setTableData] = useState(null); const [page, setPage] = useState(1); @@ -222,7 +215,7 @@ export function TableBrowser({
{t('totalRows')}:{' '} - {formatNumber(tableData.total)} + {format.number(tableData.total)} {' '} {t('records')}
diff --git a/frontend/app/(main)/admin/database/page-client.tsx b/frontend/app/(main)/admin/database/page-client.tsx index fc9ae3eb..80e6d228 100644 --- a/frontend/app/(main)/admin/database/page-client.tsx +++ b/frontend/app/(main)/admin/database/page-client.tsx @@ -3,7 +3,7 @@ import dynamic from 'next/dynamic'; import * as React from 'react'; import { useCallback, useEffect, useState } from 'react'; -import { useTranslations } from 'next-intl'; +import { useFormatter, useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { Activity, @@ -49,16 +49,9 @@ const SQLConsole = dynamic( { ssr: false }, ); -/** - * 格式化数字,每3位加逗号 - */ -const formatNumber = (num: number | string) => { - if (num === undefined || num === null) return '0'; - return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); -}; - export function DatabasePageClient() { const t = useTranslations('admin.database'); + const format = useFormatter(); // 核心状态 const [overview, setOverview] = useState(null); const [tables, setTables] = useState([]); @@ -275,7 +268,7 @@ export function DatabasePageClient() {
- {formatNumber(overview.table_count)} + {format.number(overview.table_count)}
@@ -290,7 +283,7 @@ export function DatabasePageClient() {
- {formatNumber(overview.connections)} + {format.number(overview.connections)}
diff --git a/frontend/app/(main)/admin/demo/page.tsx b/frontend/app/(main)/admin/demo/page.tsx index aaafdd69..7eba8158 100644 --- a/frontend/app/(main)/admin/demo/page.tsx +++ b/frontend/app/(main)/admin/demo/page.tsx @@ -1,8 +1,8 @@ +'use client'; + // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -'use client'; - import * as React from 'react'; import { useTranslations } from 'next-intl'; import { Code } from 'lucide-react'; @@ -22,6 +22,8 @@ import { TableTab } from './tabs/table-tab'; import { ControlsTab } from './tabs/controls-tab'; export default function HeaderDemoPage() { + const rt = useTranslations('runtime'); + const t = useTranslations('admin.demo'); const [activeTab, setActiveTab] = React.useState('dashboard'); @@ -59,7 +61,8 @@ export default function HeaderDemoPage() { {t('rules.structureFocus.description')}
  • - {t('rules.containerAlignment.title')}:使用{' '} + {t('rules.containerAlignment.title')} + {rt('use_1b3d699e')}{' '} flex items-center gap-2 {' '} @@ -67,22 +70,23 @@ export default function HeaderDemoPage() { py-6 px-1 {' '} - (或{' '} + {rt('or_07d4e3c0')}{' '} py-6 ) {t('rules.containerAlignment.description2')}
  • - {t('rules.iconPresentation.title')}:直接将 - Lucide 图标组件嵌套于标题容器中,应用{' '} + {t('rules.iconPresentation.title')} + {rt('place_the_lucide_icon_directly_inside_the_104f794c')}{' '} size-5 text-primary {' '} {t('rules.iconPresentation.description')}
  • - {t('rules.textStyle.title')}:文本使用且仅使用{' '} + {t('rules.textStyle.title')} + {rt('use_only_5e12eecb')}{' '} {'h1 className="text-2xl font-semibold tracking-tight"'} @@ -93,10 +97,8 @@ export default function HeaderDemoPage() { {t('rules.textStyle.description2')}
  • - {t('rules.tabsModular.title')}:凡是带有多个 - Tab 页切换的页面,**禁止**将所有 Tab - 的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的 - Content 必须单独拆分为独立组件文件(如{' '} + {t('rules.tabsModular.title')} + {rt('for_pages_with_multiple_tabs_keep_each_5f721959')}{' '} tabs/events-tab.tsx {' '} @@ -107,8 +109,8 @@ export default function HeaderDemoPage() { {t('rules.tabsModular.description2')}
  • - {t('rules.complexityDriven.title')}:不仅是 - Tabs 切换,当一个页面代码行数超过 600 + {t('rules.complexityDriven.title')} + {rt('beyond_tabs_when_a_page_exceeds_600_d152cfdc')} {t('rules.complexityDriven.description')}{' '} app/(main)/admin/database/components/ @@ -121,19 +123,19 @@ export default function HeaderDemoPage() { /admin/database - ) 的{' '} + {rt('message_18ab4763')}{' '} table-browser.tsx - 、 + {rt('list_separator')} cache-manager.tsx {' '} - 与{' '} + {rt('and_de1d5dd0')}{' '} sql-console.tsx - 。 + {rt('sentence_end')}
  • diff --git a/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx b/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx index 52f0d775..000586da 100644 --- a/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx +++ b/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx @@ -1,8 +1,8 @@ +'use client'; + // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -'use client'; - import * as React from 'react'; import { useTranslations } from 'next-intl'; import { Code } from 'lucide-react'; @@ -26,6 +26,8 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; export function ControlsTab() { + const rt = useTranslations('runtime'); + const t = useTranslations('admin.demo.controls'); const [formData, setFormData] = React.useState({ name: '', @@ -166,78 +168,71 @@ export function ControlsTab() { {t('spec.unifiedHeight')}

    - 输入框 ( + {rt('inputs_fabee312')} Input - ) 与下拉选择器 ( + {rt('and_selectors_e4875cdf')} SelectTrigger - ) 的高度应统一限制为小号高度{' '} + {rt('should_share_the_compact_height_f1fe2218')}{' '} h-8 - ,字体大小使用{' '} + {rt('with_font_size_e7c29991')}{' '} text-xs - ,并使用{' '} + {rt('and_use_ca398a12')}{' '} shadow-none {' '} - 去除原生的重叠阴影。 + {rt('to_remove_overlapping_native_shadows_dd65b475')}

    {t('spec.checkboxAlignment')}

    - Checkbox 应在左侧垂直偏上对齐,使用容器{' '} + {rt('align_checkboxes_toward_the_top_left_using_092dcef0')}{' '} flex items-start gap-2.5 {' '} - 排列。Checkbox 组件本身添加{' '} + {rt('add_bf15da3a')}{' '} mt-0.5 - ,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。 + {rt('to_the_checkbox_so_multiline_descriptions_stay_1bb85c4b')}

    {t('spec.buttonStrategy')}

    - 普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '} + {rt('use_the_default_primary_or_secondary_db7513e5')}{' '} {'variant="secondary"'} {' '} - 按钮,小号高度统一采用{' '} + {rt('button_for_forms_and_create_save_actions_a657dba8')}{' '} h-8 text-xs - 。线框按钮必须带有{' '} + {rt('outline_buttons_must_include_the_66ace005')}{' '} border-dashed shadow-none {' '} - 类。 + {rt('class_43eff5ff')}

    -
    {`// 1. Label 与 Input 对齐`}
    -
    {`
    - - -
    `}
    -
    {`// 2. Checkbox 顶对齐`}
    -
    {`
    - -
    - -

    多行说明内容

    -
    -
    `}
    +
    {rt('1_align_label_and_input_32d4558d')}
    +
    {rt.raw('div_classname_space_y_1_5_label_32230ad7')}
    +
    + {rt('2_align_checkbox_to_the_top_bdf68ad8')} +
    +
    {rt.raw('div_classname_flex_items_start_gap_2_ccf0d526')}
    diff --git a/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx b/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx index 9837d581..819866e7 100644 --- a/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx +++ b/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx @@ -1,8 +1,8 @@ +'use client'; + // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -'use client'; - import * as React from 'react'; import { useTranslations } from 'next-intl'; import { Code, Cpu, Database, HardDrive } from 'lucide-react'; @@ -10,6 +10,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; export function DashboardTab() { + const rt = useTranslations('runtime'); + const t = useTranslations('admin.demo.dashboard'); return (
    @@ -129,17 +131,9 @@ export function DashboardTab() {
    -            {`
    -  
    -    指标标题
    -    
    -  
    -  
    -    
    42.8 %
    - -

    状态说明小字

    -
    -
    `} + {rt.raw( + 'card_classname_border_dashed_shadow_none_cardheader_35727acf', + )}
    diff --git a/frontend/app/(main)/admin/demo/tabs/table-tab.tsx b/frontend/app/(main)/admin/demo/tabs/table-tab.tsx index 29f71b56..72a48f0a 100644 --- a/frontend/app/(main)/admin/demo/tabs/table-tab.tsx +++ b/frontend/app/(main)/admin/demo/tabs/table-tab.tsx @@ -1,8 +1,8 @@ +'use client'; + // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -'use client'; - import * as React from 'react'; import { useTranslations } from 'next-intl'; import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react'; @@ -20,6 +20,8 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; export function TableTab() { + const rt = useTranslations('runtime'); + const t = useTranslations('admin.demo.table'); const mockTableData = [ { @@ -193,7 +195,7 @@ export function TableTab() { border border-dashed shadow-none rounded-lg overflow-hidden bg-background
    - 。 + {rt('sentence_end')}
  • {t('spec.headerBg')} @@ -204,7 +206,7 @@ export function TableTab() { text-xs font-semibold - 。 + {rt('sentence_end')}
  • {t('spec.cellDivider')} @@ -215,14 +217,14 @@ export function TableTab() { hover:bg-muted/10 transition-colors - 。 + {rt('sentence_end')}
  • {t('spec.monoFont')} font-mono text-xs - 。 + {rt('sentence_end')}
  • {t('spec.badgeStyle')} @@ -233,27 +235,7 @@ export function TableTab() {
  • -            {`
    - - - - 编号 - 状态 - - - - - ID-1001 - - - - 成功 - - - - -
    -
    `} + {rt.raw('div_classname_border_border_dashed_shadow_none_b430b520')}
    diff --git a/frontend/app/(main)/admin/files/components/file-list.tsx b/frontend/app/(main)/admin/files/components/file-list.tsx index cf00aff2..6cb3e5bf 100644 --- a/frontend/app/(main)/admin/files/components/file-list.tsx +++ b/frontend/app/(main)/admin/files/components/file-list.tsx @@ -1,5 +1,6 @@ 'use client'; +import { getFormattingLocale } from '@/i18n/format'; import * as React from 'react'; import { useTranslations } from 'next-intl'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -72,7 +73,7 @@ function getFileIcon(mimeType: string, className = 'size-8') { } function formatDate(dateStr: string) { - return new Date(dateStr).toLocaleString('zh-CN', { + return new Date(dateStr).toLocaleString(getFormattingLocale(), { year: 'numeric', month: '2-digit', day: '2-digit', @@ -82,6 +83,8 @@ function formatDate(dateStr: string) { } export function FileList() { + const uiText = useTranslations('runtime'); + const t = useTranslations('admin.files'); const queryClient = useQueryClient(); const [keyword, setKeyword] = React.useState(''); @@ -564,7 +567,7 @@ export function FileList() {
    - Hash (SHA-256) + {uiText('ui_fileHash')} {detailTarget.hash} diff --git a/frontend/app/(main)/admin/files/components/file-stats.tsx b/frontend/app/(main)/admin/files/components/file-stats.tsx index a03dc0b1..ac29c235 100644 --- a/frontend/app/(main)/admin/files/components/file-stats.tsx +++ b/frontend/app/(main)/admin/files/components/file-stats.tsx @@ -50,6 +50,12 @@ const categoryMap: Record = { 其他: 'others', }; +// The API retains persisted category keys; labels always follow the UI locale. +function categoryTranslationKey(name: string) { + const key = categoryMap[name] || 'others'; + return `stats.cat${key.charAt(0).toUpperCase()}${key.slice(1)}`; +} + function useStatsChartConfig() { const t = useTranslations('admin.files'); return { @@ -158,11 +164,11 @@ export function FileStats() { stats.categories.forEach((cat) => { if (cat.count > maxCount) { maxCount = cat.count; - maxCountName = cat.name; + maxCountName = t(categoryTranslationKey(cat.name)); } if (cat.size > maxSize) { maxSize = cat.size; - maxSizeName = cat.name; + maxSizeName = t(categoryTranslationKey(cat.name)); } }); @@ -217,7 +223,7 @@ export function FileStats() {
    {stats.total_count.toLocaleString()}{' '} - {t('stats.countUnit')} + {t.raw('stats.countUnit')}

    @@ -254,7 +260,7 @@ export function FileStats() {

    +{trendSummary.count.toLocaleString()}{' '} - {t('stats.countUnit')} + {t.raw('stats.countUnit')}

    @@ -378,7 +384,7 @@ export function FileStats() { const formattedValue = trendMetric === 'size' ? formatFileSize(Number(value)) - : `${value} ${t('stats.countUnit')}`; + : `${value} ${t.raw('stats.countUnit')}`; return ( <>

    @@ -488,7 +494,7 @@ export function FileStats() {
    - {value} {t('stats.countUnit')} + {value} {t.raw('stats.countUnit')} ); diff --git a/frontend/app/(main)/admin/files/components/storage-config-tab.tsx b/frontend/app/(main)/admin/files/components/storage-config-tab.tsx index 11ae498f..95ad0c74 100644 --- a/frontend/app/(main)/admin/files/components/storage-config-tab.tsx +++ b/frontend/app/(main)/admin/files/components/storage-config-tab.tsx @@ -501,13 +501,15 @@ function ObjectFields({ value: ObjectStorageConfig; onChange: (patch: Partial) => void; }) { + const uiText = useTranslations('runtime'); + const t = useTranslations('admin.files'); return ( <> {driver === 'r2' && (
    onChange({ account_id })} /> @@ -516,7 +518,7 @@ function ObjectFields({ {driver !== 's3' && (
    onChange({ endpoint })} @@ -524,22 +526,22 @@ function ObjectFields({
    )} onChange({ region })} /> onChange({ bucket })} /> onChange({ access_key_id })} /> onChange({ secret_access_key })} @@ -564,7 +566,7 @@ function ObjectFields({ >
    - Path Style + {uiText('ui_pathStyle')} {t('storage.pathStyleDesc')} diff --git a/frontend/app/(main)/admin/logs/components/access-analytics.tsx b/frontend/app/(main)/admin/logs/components/access-analytics.tsx index 43343fa2..280a28e0 100644 --- a/frontend/app/(main)/admin/logs/components/access-analytics.tsx +++ b/frontend/app/(main)/admin/logs/components/access-analytics.tsx @@ -17,6 +17,7 @@ limitations under the License. 'use client'; import { useCallback, useEffect, useState } from 'react'; +import { isLogStorageDisabledMessage } from './log-storage-error'; import { BarChart3, Globe, @@ -117,7 +118,7 @@ export function AccessAnalytics() { const errorInstance = err instanceof Error ? err : new Error(t('fetchFailed')); const errMsg = errorInstance.message || ''; - if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) { + if (isLogStorageDisabledMessage(errMsg)) { setClickhouseDisabled(true); } else { setError(errorInstance); diff --git a/frontend/app/(main)/admin/logs/components/access-logs.tsx b/frontend/app/(main)/admin/logs/components/access-logs.tsx index b128132a..c8179f94 100644 --- a/frontend/app/(main)/admin/logs/components/access-logs.tsx +++ b/frontend/app/(main)/admin/logs/components/access-logs.tsx @@ -1,3 +1,5 @@ +'use client'; + /* Copyright 2026 Arctel.net @@ -14,9 +16,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use client'; - import { useMemo, useState } from 'react'; +import { isLogStorageDisabledMessage } from './log-storage-error'; import { useQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format } from 'date-fns'; @@ -94,10 +95,12 @@ function statusVariant(status: number) { function isClickhouseDisabledError(error: Error) { const errMsg = error.message || ''; - return errMsg.includes('ClickHouse') || errMsg.includes('未启用'); + return isLogStorageDisabledMessage(errMsg); } export function AccessLogs() { + const uiText = useTranslations('runtime'); + const t = useTranslations('admin.logs.accessLogs'); const [page, setPage] = useState(1); @@ -519,7 +522,7 @@ export function AccessLogs() {
    -
    +
    - + {newEventType === 'task' ? t('taskTemplateVars') : t('eventTemplateVars')} @@ -721,7 +723,11 @@ export function EventsTab() { onChange={(e) => setNewEventTemplate(e.target.value)} rows={6} className='text-xs font-mono' - placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}' + placeholder={rt.raw( + newEventType === 'task' + ? 'task_notification_template_example' + : 'title_administrator_login_alert_content_administrator_user_6f32cf7c', + )} />
    @@ -886,11 +892,11 @@ export function EventsTab() {
    -
    +
    - + {selectedEvent.task_type ? t('taskTemplateVars') : t('eventTemplateVars')} @@ -901,7 +907,11 @@ export function EventsTab() { onChange={(e) => setEventTemplate(e.target.value)} rows={6} className='text-xs font-mono' - placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}' + placeholder={rt.raw( + selectedEvent.task_type + ? 'task_notification_template_example' + : 'title_administrator_login_alert_content_administrator_user_6f32cf7c', + )} />
    diff --git a/frontend/app/(main)/admin/push/components/settings-tab.tsx b/frontend/app/(main)/admin/push/components/settings-tab.tsx index 30153700..b43d4a24 100644 --- a/frontend/app/(main)/admin/push/components/settings-tab.tsx +++ b/frontend/app/(main)/admin/push/components/settings-tab.tsx @@ -1,8 +1,8 @@ +'use client'; + // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -'use client'; - import * as React from 'react'; import { useTranslations } from 'next-intl'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -62,6 +62,8 @@ import type { import { PushService } from '@/lib/services/push'; export function SettingsTab() { + const rt = useTranslations('runtime'); + const t = useTranslations('admin.push.settings'); const queryClient = useQueryClient(); const [deleteTarget, setDeleteTarget] = React.useState( @@ -606,7 +608,7 @@ export function SettingsTab() { toast.success(t('loadedFeishuTemplate')); }} > - 飞书 Webhook + {rt('lark_webhook_c2e0b312')}
    diff --git a/frontend/app/(main)/admin/settings/components/openflare-ops.tsx b/frontend/app/(main)/admin/settings/components/openflare-ops.tsx index 0f092349..24aaec95 100644 --- a/frontend/app/(main)/admin/settings/components/openflare-ops.tsx +++ b/frontend/app/(main)/admin/settings/components/openflare-ops.tsx @@ -64,6 +64,8 @@ async function copyText(value: string) { } export function OpenFlareOpsSettings() { + const uiText = useTranslations('runtime'); + const t = useTranslations('openflareOps'); const queryClient = useQueryClient(); const [fields, setFields] = useState( @@ -346,7 +348,7 @@ export function OpenFlareOpsSettings() { value={geoIPMutation.data.name || '—'} />
    @@ -427,7 +429,7 @@ export function OpenFlareOpsSettings() { updateField('server_address', value)} placeholder='https://yourdomain.com' diff --git a/frontend/app/(main)/admin/settings/components/system-status.tsx b/frontend/app/(main)/admin/settings/components/system-status.tsx index 5303b62a..b4c86672 100644 --- a/frontend/app/(main)/admin/settings/components/system-status.tsx +++ b/frontend/app/(main)/admin/settings/components/system-status.tsx @@ -1,7 +1,9 @@ 'use client'; import * as React from 'react'; -import { useTranslations } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; +import { normalizeLocale } from '@/i18n/config'; +import { formatSystemMetric } from '@/lib/utils/system-metrics'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { @@ -34,19 +36,12 @@ import { Skeleton } from '@/components/ui/skeleton'; import type { SystemStatus } from '@/lib/services/admin'; import services from '@/lib/services'; -/** - * 格式化数字,每3位加逗号 - */ -const formatNumber = (num: number | string) => { - if (num === undefined || num === null) return '0'; - return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ','); -}; - /** * 运行时系统状态展示与管理组件 */ export function SystemStatusManager() { const t = useTranslations('openflareOps.status'); + const locale = normalizeLocale(useLocale()); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [wavelet, setWavelet] = useState(false); @@ -164,7 +159,7 @@ export function SystemStatusManager() { - {typeof value === 'number' ? formatNumber(value) : value} + {formatSystemMetric(value, locale)}
    ); diff --git a/frontend/app/(main)/admin/system/page.tsx b/frontend/app/(main)/admin/system/page.tsx index b31e9cf9..2d7992be 100644 --- a/frontend/app/(main)/admin/system/page.tsx +++ b/frontend/app/(main)/admin/system/page.tsx @@ -1,7 +1,8 @@ 'use client'; import * as React from 'react'; -import { useTranslations } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; +import { localizeConfigDescription } from '@/i18n/builtin-metadata'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { @@ -52,6 +53,7 @@ function SystemConfigDetailPanel({ saving: boolean; }) { const t = useTranslations('admin.system'); + const locale = useLocale(); return (
    @@ -138,8 +140,16 @@ function SystemConfigDetailPanel({ type='text' value={ editData.description !== undefined - ? editData.description - : config?.description || '' + ? localizeConfigDescription( + config?.key ?? '', + editData.description, + locale, + ) + : localizeConfigDescription( + config?.key ?? '', + config?.description || '', + locale, + ) } placeholder={t('optionalDescription')} onChange={(e) => { @@ -205,6 +215,7 @@ function SystemConfigDetailPanel({ */ export function SystemConfigs() { const t = useTranslations('admin.system'); + const locale = useLocale(); const { systemConfigs: configs, systemConfigsLoading: loading, @@ -309,9 +320,13 @@ export function SystemConfigs() { cell: (item) => ( - {item.description} + {localizeConfigDescription(item.key, item.description, locale)} ), width: '200px', diff --git a/frontend/app/(main)/admin/tasks/components/task-manager.tsx b/frontend/app/(main)/admin/tasks/components/task-manager.tsx index 1bdfc1f9..0c2ce855 100644 --- a/frontend/app/(main)/admin/tasks/components/task-manager.tsx +++ b/frontend/app/(main)/admin/tasks/components/task-manager.tsx @@ -47,7 +47,6 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { format } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; import { Calendar } from '@/components/ui/calendar'; import { Popover, @@ -177,7 +176,6 @@ function DatePickerWithTime({ mode='single' selected={date} onSelect={handleDateSelect} - locale={zhCN} /> diff --git a/frontend/app/(main)/admin/users/components/user-detail-sheet.tsx b/frontend/app/(main)/admin/users/components/user-detail-sheet.tsx index 083f8e1f..74566c60 100644 --- a/frontend/app/(main)/admin/users/components/user-detail-sheet.tsx +++ b/frontend/app/(main)/admin/users/components/user-detail-sheet.tsx @@ -34,6 +34,8 @@ export function UserDetailSheet({ detailLoading, onStatusToggle, }: UserDetailSheetProps) { + const uiText = useTranslations('runtime'); + const t = useTranslations('admin.users'); const displayValue = (value?: string) => value && value.trim() ? value : '-'; @@ -72,7 +74,7 @@ export function UserDetailSheet({ {selectedUser.is_admin && ( - Admin + {uiText('ui_administrator')} )}
    diff --git a/frontend/app/(main)/apply-logs/components/log-detail-sheet.tsx b/frontend/app/(main)/apply-logs/components/log-detail-sheet.tsx index b48d7e94..28f6884b 100644 --- a/frontend/app/(main)/apply-logs/components/log-detail-sheet.tsx +++ b/frontend/app/(main)/apply-logs/components/log-detail-sheet.tsx @@ -61,6 +61,8 @@ export function LogDetailSheet({ open, onOpenChange, }: LogDetailSheetProps) { + const uiText = useTranslations('runtime'); + const t = useTranslations('applyLogs'); return ( @@ -75,7 +77,7 @@ export function LogDetailSheet({
    - Node: {log.node_id} + {uiText('ui_nodeLabel')} {log.node_id} {t('versionBadge', { version: log.version })} diff --git a/frontend/app/(main)/cloudflare/components/sync-tasks-panel.tsx b/frontend/app/(main)/cloudflare/components/sync-tasks-panel.tsx index 8ff0be52..79393883 100644 --- a/frontend/app/(main)/cloudflare/components/sync-tasks-panel.tsx +++ b/frontend/app/(main)/cloudflare/components/sync-tasks-panel.tsx @@ -130,6 +130,8 @@ function statusVariant(status: TaskExecutionStatus) { } export function SyncTasksPanel() { + const uiText = useTranslations('runtime'); + const t = useTranslations('cloudflare.tasks'); const queryClient = useQueryClient(); const [page, setPage] = useState(1); @@ -469,7 +471,9 @@ export function SyncTasksPanel() { ) : null}
    -
    Payload
    +
    + {uiText('ui_payload')} +
                         {selected.payload || '{}'}
                       
    diff --git a/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx b/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx index 5093ed1c..4ed4d4ae 100644 --- a/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx +++ b/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx @@ -67,6 +67,8 @@ function getGroupIdFromPathname(pathname: string | null): number { } export function CloudflareGroupDetailPageClient() { + const uiText = useTranslations('runtime'); + const t = useTranslations('cloudflare'); const tCommon = useTranslations('common'); const [mounted, setMounted] = useState(false); @@ -449,7 +451,7 @@ export function CloudflareGroupDetailPageClient() { setSelectedMemberIDs(new Set()); } }} - aria-label='Select all' + aria-label={uiText('ui_selectAll')} /> {t('columns.domain')} diff --git a/frontend/app/(main)/components/dashboard/node-rank-panel.tsx b/frontend/app/(main)/components/dashboard/node-rank-panel.tsx index aa4366d2..fead5136 100644 --- a/frontend/app/(main)/components/dashboard/node-rank-panel.tsx +++ b/frontend/app/(main)/components/dashboard/node-rank-panel.tsx @@ -1,5 +1,6 @@ 'use client'; +import { getFormattingLocale } from '@/i18n/format'; import { useTranslations } from 'next-intl'; import { RankChart } from '@/components/data/rank-chart'; @@ -16,7 +17,7 @@ function buildNodeRankItems( const leftValue = selector(left); const rightValue = selector(right); if (leftValue === rightValue) { - return left.name.localeCompare(right.name, 'zh-CN'); + return left.name.localeCompare(right.name, getFormattingLocale()); } return rightValue - leftValue; }) diff --git a/frontend/app/(main)/components/dashboard/world-stage-map.tsx b/frontend/app/(main)/components/dashboard/world-stage-map.tsx index 7384e100..a1b970b7 100644 --- a/frontend/app/(main)/components/dashboard/world-stage-map.tsx +++ b/frontend/app/(main)/components/dashboard/world-stage-map.tsx @@ -1,5 +1,6 @@ 'use client'; +import { getFormattingLocale } from '@/i18n/format'; import ReactEChartsCore from 'echarts-for-react/lib/core'; import type { EChartsCoreOption } from 'echarts/core'; import * as echarts from 'echarts/core'; @@ -506,7 +507,7 @@ export function WorldStageMap({ ) { return [ `
    ${payload.name ?? data.name}
    `, - `
    ${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}
    `, + `
    ${t('sourceRequests', { count: data.value.toLocaleString(getFormattingLocale()) })}
    `, ].join(''); } @@ -522,8 +523,8 @@ export function WorldStageMap({ `
    ${data.name}
    `, `
    ${locationLine}
    `, `
    ${t('requestsErrors', { - requests: data.requestCount.toLocaleString('zh-CN'), - errors: data.errorCount.toLocaleString('zh-CN'), + requests: data.requestCount.toLocaleString(getFormattingLocale()), + errors: data.errorCount.toLocaleString(getFormattingLocale()), })}
    `, `
    ${t('eventsStatus', { events: data.activeEventCount, diff --git a/frontend/app/(main)/components/placeholder-page.tsx b/frontend/app/(main)/components/placeholder-page.tsx index cf3c9150..c1b9100e 100644 --- a/frontend/app/(main)/components/placeholder-page.tsx +++ b/frontend/app/(main)/components/placeholder-page.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import type { LucideIcon } from 'lucide-react'; import { @@ -13,6 +16,8 @@ interface PlaceholderPageProps { } export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) { + const rt = useTranslations('runtime'); + return (
    @@ -22,8 +27,12 @@ export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) { - 开发中 - 该功能正在迁移开发中,敬请期待。 + + {rt('under_development_78c33fbf')} + + + {rt('this_feature_is_being_migrated_and_developed_61ebaf78')} +
    diff --git a/frontend/app/(main)/nodes/components/edge-node-detail.tsx b/frontend/app/(main)/nodes/components/edge-node-detail.tsx index e18964a6..95e3e274 100644 --- a/frontend/app/(main)/nodes/components/edge-node-detail.tsx +++ b/frontend/app/(main)/nodes/components/edge-node-detail.tsx @@ -64,6 +64,7 @@ const nodesQueryKey = ['openflare', 'nodes']; export function EdgeNodeDetail({ node }: { node: NodeItem }) { const t = useTranslations('nodes'); + const rt = useTranslations('runtime'); const tc = useTranslations('common'); const router = useRouter(); const queryClient = useQueryClient(); @@ -320,7 +321,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) { <> = { all: t('filter.all'), - edge: 'Edge', - relay: 'Relay', - tunnel: 'Tunnel', + edge: rt('node_type_edge'), + relay: rt('node_type_relay'), + tunnel: rt('node_type_tunnel'), }; return ( diff --git a/frontend/app/(main)/nodes/components/node-utils.ts b/frontend/app/(main)/nodes/components/node-utils.ts index 6510fa9f..a4c9a0cd 100644 --- a/frontend/app/(main)/nodes/components/node-utils.ts +++ b/frontend/app/(main)/nodes/components/node-utils.ts @@ -1,3 +1,5 @@ +import { getFormattingLocale } from '@/i18n/format'; +import { runtimeText } from '@/i18n/runtime'; import type { ApplyResult, NodeItem, @@ -111,9 +113,9 @@ export function getRelayStatusLabel( } export function getNodeTypeLabel(nodeType: NodeItem['node_type']) { - if (nodeType === 'tunnel_relay') return 'Relay'; - if (nodeType === 'tunnel_client') return 'Tunnel'; - return 'Edge'; + if (nodeType === 'tunnel_relay') return runtimeText('node_type_relay'); + if (nodeType === 'tunnel_client') return runtimeText('node_type_tunnel'); + return runtimeText('node_type_edge'); } export function getErrorMessage(error: unknown, fallback: string) { @@ -255,7 +257,7 @@ export function formatMetricCount(value?: number | null) { if (value === undefined || value === null || !Number.isFinite(value)) { return '—'; } - return value.toLocaleString('zh-CN'); + return value.toLocaleString(getFormattingLocale()); } export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) { diff --git a/frontend/app/(main)/nodes/components/relay-node-detail.tsx b/frontend/app/(main)/nodes/components/relay-node-detail.tsx index 1debd830..d54baddd 100644 --- a/frontend/app/(main)/nodes/components/relay-node-detail.tsx +++ b/frontend/app/(main)/nodes/components/relay-node-detail.tsx @@ -79,6 +79,8 @@ function systemConfigMap(configs: SystemConfig[]) { } export function RelayNodeDetail({ node }: { node: NodeItem }) { + const rt = useTranslations('runtime'); + const t = useTranslations('nodes'); const tc = useTranslations('common'); const router = useRouter(); @@ -116,7 +118,9 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) { 'relay_frps_web_ui_port', { value: String(portNum), - description: portCfg?.description || 'FRPS 内置 Web 管理界面端口', + description: + portCfg?.description || + rt('port_for_the_built_in_frps_web_cfab540f'), }, ); }, @@ -485,7 +489,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) { <> - Production + {uiText('ui_production')} {t('history.productionHint')} ))}
    diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx index 5a498a93..a5319625 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx @@ -1,6 +1,6 @@ import { CircleHelp, Settings2 } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -35,8 +35,8 @@ import { } from '@/components/ui/tooltip'; import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare'; -import { countryOptions, regionOptions, type GeoOption } from './geo-options'; -import { NODE_TYPE_LABELS } from './node-factory'; +import { createGeoOptions, type GeoOption } from './geo-options'; +import { NODE_TYPE_LABEL_KEYS } from './node-factory'; import { UA_BROWSER_OPTIONS, UA_OS_OPTIONS } from './ua-options'; export function NodeProperties({ @@ -78,6 +78,14 @@ function PropertyFields({ onChange: (node: WAFRuleNode) => void; }) { const t = useTranslations('waf.editor'); + const locale = useLocale(); + const { countryOptions, regionOptions } = useMemo( + () => + node.type === 'geo_match' + ? createGeoOptions(locale) + : { countryOptions: [], regionOptions: [] }, + [locale, node.type], + ); if (node.type === 'start' || node.type === 'allow') return (

    {t('systemNodeNoConfig')}

    @@ -518,13 +526,14 @@ function DisplayNameField({ onChange: (node: WAFRuleNode) => void; }) { const t = useTranslations('waf.editor'); + const rt = useTranslations('runtime'); return ( {t('displayName')} onChange({ ...node, label: event.target.value })} /> diff --git a/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx b/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx index e7b72782..56d3ee50 100644 --- a/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx @@ -1,4 +1,5 @@ import { Handle, Position, type NodeProps } from '@xyflow/react'; +import { useTranslations } from 'next-intl'; import { Ban, Fingerprint, @@ -42,10 +43,11 @@ const outputHandles: Partial> = { }; export function RuleNode({ data, selected }: NodeProps) { + const rt = useTranslations('runtime'); const value = data as RuleFlowNodeData; const { rule, issues } = value; const { icon: Icon } = meta[rule.type]; - const title = displayNodeTitle(rule); + const title = displayNodeTitle(rule, rt); return (
    ( diff --git a/frontend/app/(main)/waf/rules/editor/page.tsx b/frontend/app/(main)/waf/rules/editor/page.tsx index bd0f365b..de5ecd66 100644 --- a/frontend/app/(main)/waf/rules/editor/page.tsx +++ b/frontend/app/(main)/waf/rules/editor/page.tsx @@ -219,8 +219,8 @@ function EditorContent() { {t('back')} -
    -
    +
    +

    {ruleQuery.data.name} @@ -235,7 +235,7 @@ function EditorContent() { {dirty && {t('unsaved')}}

    -
    +
    value - .toLocaleDateString('en-GB', { day: 'numeric', month: 'long' }) + .toLocaleDateString(getFormattingLocale(), { + day: 'numeric', + month: 'long', + }) .toUpperCase(); return `${fmt(start)} — ${fmt(end)}`; } diff --git a/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx b/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx index a0142bc7..ebc8a4ee 100644 --- a/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx +++ b/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx @@ -65,6 +65,8 @@ export function QuickCreateZoneDomainDialog({ zones?: ZoneItem[]; onCreated(domain: ZoneDomainItem): void | Promise; }) { + const uiText = useTranslations('runtime'); + const t = useTranslations('websites'); const tc = useTranslations('common'); const queryClient = useQueryClient(); @@ -214,7 +216,7 @@ export function QuickCreateZoneDomainDialog({ > {!fixedZoneId ? (
    - + \n
    ", + "2_align_checkbox_to_the_top_bdf68ad8": "// 2. Align Checkbox to the top", + "div_classname_flex_items_start_gap_2_ccf0d526": "
    \n \n
    \n \n

    Multiline description

    \n
    \n
    ", + "card_classname_border_dashed_shadow_none_cardheader_35727acf": "\n \n Metric title\n \n \n \n
    42.8 %
    \n \n

    Status description

    \n
    \n
    ", + "div_classname_border_border_dashed_shadow_none_b430b520": "
    \n \n \n \n ID\n Status\n \n \n \n \n ID-1001\n \n \n \n Success\n \n \n \n \n
    \n
    ", + "disabled_f95ea7f4": "Disabled", + "title_administrator_login_alert_content_administrator_user_6f32cf7c": "{\"title\": \"Administrator login alert\", \"content\": \"Administrator {{user.username}} ...\", \"level\": \"INFO\"}", + "lark_webhook_c2e0b312": "Lark Webhook", + "title_content_view_details_url_7af4b7d5": "### $title\n$content\n\n[View details]($url)", + "dingtalk_group_bot_5d8efec9": "DingTalk group bot", + "wecom_group_bot_138ea7a8": "WeCom group bot", + "under_development_78c33fbf": "Under development", + "this_feature_is_being_migrated_and_developed_61ebaf78": "This feature is being migrated and developed. Please check back later.", + "port_for_the_built_in_frps_web_cfab540f": "Port for the built-in FRPS web administration interface", + "request_failed_please_try_again_later_8977b79a": "Request failed. Please try again later.", + "operation_failed_0c3b4cf7": "Operation failed", + "automatic_configuration_must_be_a_json_object_90ba17a4": "Automatic configuration must be a JSON object.", + "high_frequency_404_scan_from_one_ip_f7d27ffb": "High-frequency 404 scan from one IP", + "abnormal_direct_access_from_one_ip_732dbeee": "Abnormal direct access from one IP", + "permanent_3e71ccc8": "Permanent", + "expired_2fe0e333": "Expired", + "in_value0_minutes_fd2a5b53": "In {value0} minutes", + "in_value0_hours_5ca37144": "In {value0} hours", + "node_2410d860": "Node", + "edge_099db313": "Edge", + "edge_s_id_74f6affc": "Edge(?:\\s+ID)?", + "value0_s_value1_s_40960c98": "{value0}\\s+{value1}(?=$|[\\s,.:])", + "rule_graph_schema_version_must_be_1_ea9a1161": "Rule graph schema_version must be 1", + "rule_graph_exceeds_the_size_limit_337651b9": "Rule graph exceeds the size limit", + "node_id_must_not_be_empty_51a5de58": "Node ID must not be empty", + "duplicate_node_id_value0_621230af": "Duplicate node ID {value0}", + "edge_id_must_not_be_empty_bc8f69cb": "Edge ID must not be empty", + "duplicate_edge_id_value0_0a9d1c7c": "Duplicate edge ID {value0}", + "the_start_node_must_not_have_incoming_50a07b17": "The start node must not have incoming edges", + "the_rule_graph_must_contain_exactly_one_7dfbc027": "The rule graph must contain exactly one start node", + "the_rule_graph_must_contain_exactly_one_0036eb82": "The rule graph must contain exactly one allow node", + "edge_value0_has_an_invalid_endpoint_or_9d84eb7e": "Edge {value0} has an invalid endpoint or output", + "node_value0_output_value1_is_not_connected_c3038e81": "Node {value0} output {value1} is not connected", + "node_value0_output_value1_can_only_be_903e54ce": "Node {value0} output {value1} can only be connected once", + "node_value0_is_unreachable_from_the_start_086531dc": "Node {value0} is unreachable from the start node", + "node_value0_cannot_reach_a_terminal_node_8de2ff6e": "Node {value0} cannot reach a terminal node", + "the_rule_graph_must_not_contain_cycles_fd6fe797": "The rule graph must not contain cycles", + "node_value0_contains_an_invalid_ip_address_1873ab94": "Node {value0} contains an invalid IP address", + "node_value0_contains_an_invalid_cidr_00c880d9": "Node {value0} contains an invalid CIDR", + "node_value0_contains_an_invalid_ip_group_589219a5": "Node {value0} contains an invalid IP group", + "node_value0_contains_an_invalid_geography_code_1a9f469b": "Node {value0} contains an invalid geography code", + "node_value0_has_pow_settings_outside_the_9fd2a4cb": "Node {value0} has PoW settings outside the allowed range", + "node_value0_has_invalid_block_response_settings_ae4c35ca": "Node {value0} has invalid block response settings", + "node_value0_match_mode_must_be_and_242c1ae7": "Node {value0} match mode must be and or or", + "node_value0_contains_an_invalid_browser_tag_4d0e6740": "Node {value0} contains an invalid browser tag", + "node_value0_contains_an_invalid_operating_system_45bea1fb": "Node {value0} contains an invalid operating system tag", + "node_value0_allows_at_most_32_custom_497938c6": "Node {value0} allows at most 32 custom UA patterns", + "node_value0_custom_ua_pattern_must_not_a72718be": "Node {value0} custom UA pattern must not be empty", + "node_value0_custom_ua_pattern_is_too_c2c83c0e": "Node {value0} custom UA pattern is too long", + "node_value0_has_an_invalid_custom_ua_c47ffd0d": "Node {value0} has an invalid custom UA pattern", + "node_value0_requires_at_least_one_pattern_6114bb93": "Node {value0} requires at least one pattern when custom UA blocking is enabled", + "start_d2bb025a": "Start", + "ip_match_43f4b6d4": "IP match", + "geography_match_9acb08c4": "Location match", + "ua_check_b56dac23": "UA check", + "security_checks_e09822e6": "Security checks", + "pow_challenge_3c043088": "PoW challenge", + "allow_1e9f2561": "Allow", + "block_2a366d09": "Block", + "wechat_81b430b6": "WeChat", + "wildcard_c6b0739e": "Wildcard", + "exact_match_391fe725": "Exact match", + "value0_expires_value1_7d8600f8": "{value0} (expires: {value1})", + "sorry_the_page_you_requested_seems_to_ba66cd83": "The page you requested could not be found.", + "check_the_path_or_return_to_safety_b1f31888": "Check the URL or return to the dashboard.", + "previous_c9b9ae7a": "Previous", + "home_203c08e0": "Home", + "1_api_conventions_and_authentication_e6d4df61": "1. API conventions and authentication", + "standard_api_request_conventions_and_developer_access_39eb956e": "Standard API request conventions and developer access token authentication", + "1_1_standard_response_format_5cddae2f": "1.1 Standard response format", + "all_api_endpoints_use_a_standard_json_87c92fb0": "All API endpoints use a standard JSON response structure:", + "field_49ecd0e3": "Field", + "type_ba40014f": "Type", + "description_4262c45d": "Description", + "error_message_empty_on_success_contains_error_d89d457f": "Error message. Empty (\"\") on success; contains error details on failure.", + "the_response_payload_null_when_the_request_97b8b29c": "The response payload. null when the request fails or no data is returned.", + "successful_response_example_8917e853": "Successful response example:", + "failed_response_example_eae899e1": "Failed response example:", + "error_msg_incorrect_password_data_null_b2eccc86": "{\n \"error_msg\": \"Incorrect password\",\n \"data\": null\n}", + "1_2_authentication_methods_3c5db38a": "1.2 Authentication methods", + "protected_endpoints_require_credentials_except_public_endpoints_1fa0a24c": "Authentication is required except for public endpoints, such as login, registration, and public configuration:", + "session_credentials_de72024d": "Session credentials:", + "browser_clients_can_stay_signed_in_using_f316279b": "Browser clients can stay signed in using a standard session cookie.", + "access_tokens_8de23550": "Access tokens:", + "for_backend_calls_and_third_party_integrations_dfd8bf0f": "For backend calls and third-party integrations. Generate an API access token and include it in a request header to authenticate.", + "use_either_of_the_following_request_headers_1e03b04b": "Use either of the following request headers:", + "2_user_and_authentication_endpoints_4a89d429": "2. User and authentication endpoints", + "2_1_user_registration_4f332f85": "2.1 User registration", + "endpoint_79a2369a": "Endpoint:", + "description_3e633a20": "Description:", + "register_a_local_account_when_registration_is_938891dc": "Register a local account when registration is enabled in the administration settings.", + "parameter_9634fb08": "Parameter", + "required_11da9dc4": "Required", + "yes_b5141d3d": "Yes", + "username_must_be_unique_and_contain_no_e81c1e5c": "Username; must be unique and contain no spaces.", + "password_at_least_8_characters_37cdc807": "Password; at least 8 characters.", + "no_0c70665b": "No", + "nickname_defaults_to_the_username_when_omitted_676aae7e": "Nickname. Defaults to the username when omitted.", + "2_2_password_login_271483ba": "2.2 Password login", + "authenticate_with_a_username_and_password_establishing_a38ff252": "Authenticate with a username and password, establishing a session cookie on success.", + "username_1a3f0617": "Username", + "password_a621ab60": "Password", + "2_3_sign_out_3252611d": "2.3 Sign out", + "delete_the_current_session_cookie_and_sign_c213ffd9": "Delete the current session cookie and sign out.", + "2_4_get_profile_7b96110a": "2.4 Get profile", + "get_the_signed_in_account_s_basic_b0eb8cfa": "Get the signed-in account's basic information, including ID, role, and nickname.", + "3_personal_access_token_endpoints_989f5d7c": "3. Personal access token endpoints", + "access_token_management_requires_a_session_login_42d91543": "Access token management requires a session login and is available to regular users.", + "3_1_list_tokens_9840c227": "3.1 List tokens", + "list_the_current_user_s_tokens_with_c34d2210": "List the current user's tokens with secret values masked.", + "3_2_create_an_access_token_e53bd784": "3.2 Create an access token", + "parameters_ba89685c": "Parameters:", + "name_token_name_is_admin_false_4f3e4868": "{\"name\": \"token name\", \"is_admin\": false}", + "generate_an_access_token_the_response_includes_70552390": "Generate an access token. The response includes its plaintext value once; store it securely.", + "optional_default_210b9d8f": " (optional, default ", + "grant_administrator_privileges_to_the_token_only_2292cad8": "): grant administrator privileges to the token. Only administrators can enable this. Regular tokens cannot access ", + "endpoints_815c88e5": " endpoints.", + "successful_response_example_7783a39c": "Successful response example:", + "3_3_revoke_delete_a_token_ae510f58": "3.3 Revoke/delete a token", + "permanently_delete_a_token_by_id_immediately_145287f6": "Permanently delete a token by ID, immediately invalidating it.", + "3_4_rotate_a_token_secret_79209983": "3.4 Rotate a token secret", + "replace_a_token_s_secret_and_return_d2caca89": "Replace a token's secret and return its new plaintext value. The previous secret becomes invalid; its name and ID stay the same.", + "4_public_configuration_and_administration_17ebdbd5": "4. Public configuration and administration", + "4_1_public_system_configuration_2be912e4": "4.1 Public system configuration", + "get_publicly_visible_configuration_keys_and_values_bc7bff66": "Get publicly visible configuration keys and values for dynamic frontend rendering.", + "response_example_d3087445": "Response example:", + "4_2_system_configuration_crud_administrator_2ce66c8b": "4.2 System configuration CRUD (administrator)", + "manage_system_configs_dynamically_through_an_administrator_acdcdb99": "Manage system_configs dynamically through an administrator session.", + "list_configuration_3d58680b": "List configuration:", + "create_a_configuration_entry_1d995264": "Create a configuration entry:", + "update_a_configuration_value_2a0b0b76": "Update a configuration value:", + "delete_a_configuration_entry_1b77a6ea": "Delete a configuration entry:", + "1_quick_start_ef8fbb58": "1. Quick start", + "a_guide_to_the_general_purpose_full_9cd8c987": "A guide to the general-purpose full-stack development platform", + "architecture_e992b65c": "Architecture:", + "go_gin_gorm_redis_asynq_backend_react_d5fe02d3": "Go (Gin + GORM + Redis + Asynq) backend + React (Next.js 16 + Tailwind CSS 4 + shadcn/ui) frontend", + "authentication_d36b345b": "Authentication:", + "local_username_password_registration_and_login_plus_583ab638": "Local username/password registration and login, plus third-party OIDC (OAuth2) account linking", + "access_tokens_b7b856d3": "Access tokens:", + "personal_developer_access_tokens_api_keys_for_4b407149": "Personal developer access tokens (API keys) for calling system APIs through HTTP headers", + "observability_ea0cc1bf": "Observability:", + "zap_structured_logging_and_opentelemetry_distributed_tracing_9fd6da9f": "Zap structured logging and OpenTelemetry distributed tracing", + "2_authentication_and_security_settings_76967059": "2. Authentication and security settings", + "the_platform_combines_authentication_methods_to_support_bc9f1772": "The platform combines authentication methods to support different deployments and use cases:", + "2_1_username_and_password_authentication_97184f8f": "2.1 Username and password authentication", + "registration_and_password_login_c5bd6cb4": "Registration and password login:", + "users_can_register_and_create_sessions_with_21883e04": "Users can register and create sessions with a username and password. Passwords are stored using ", + "salted_hashing_dc2a9b63": " salted hashing.", + "system_switches_53741557": "System switches:", + "administrators_can_disable_password_registration_or_login_a2648e62": "Administrators can disable password registration or login at any time to require third-party authentication.", + "2_2_third_party_oidc_providers_f14083c8": "2.2 Third-party OIDC providers", + "users_can_link_external_accounts_from_their_c08d3f5a": "Users can link external accounts from their profile page:", + "open_4540d74d": "Open ", + "settings_profile_32835b0d": "Settings / Profile", + "message_a173cce8": ".", + "under_linked_accounts_view_existing_connections_or_5151eb9b": "Under Linked accounts, view existing connections or select an available OIDC provider to start an OAuth2 linking flow.", + "after_linking_use_the_oidc_login_button_6095b15d": "After linking, use the OIDC login button on the login page for quick access.", + "3_integrating_personal_access_tokens_af2647b5": "3. Integrating personal access tokens", + "personal_access_tokens_allow_developers_and_third_9a242a0e": "Personal access tokens allow developers and third-party tools to call system APIs directly.", + "3_1_token_generation_and_storage_2b78c681": "3.1 Token generation and storage", + "one_time_plaintext_display_fd13912a": "One-time plaintext display:", + "a_newly_generated_plaintext_token_such_as_57eb11a1": "A newly generated plaintext token (such as ", + "is_displayed_only_once_copy_and_save_8d378c8a": ") is displayed only once. Copy and save it immediately; it cannot be retrieved after closing the dialog.", + "secure_hash_storage_1ce392ff": "Secure hash storage:", + "only_a_sha_256_hash_of_the_dbf434b1": "Only a SHA-256 hash of the token is stored in the database, preventing plaintext recovery from a leaked digest.", + "3_2_calling_apis_with_headers_fccd7b0d": "3.2 Calling APIs with headers", + "use_your_saved_plaintext_token_with_either_3af53a05": "Use your saved plaintext token with either of the following HTTP authentication headers:", + "method_1_authorization_bearer_header_2a9c2a6c": "Method 1: Authorization Bearer header", + "method_2_custom_x_access_token_header_4548169f": "Method 2: Custom X-Access-Token header", + "4_dynamic_system_configuration_b3bf8c49": "4. Dynamic system configuration", + "the_built_in_key_value_configuration_module_713f6d05": "The built-in key/value configuration module lets administrators adjust runtime behavior dynamically:", + "cached_reads_35828322": "Cached reads:", + "configuration_is_read_from_the_database_through_3e9f6a34": "Configuration is read from the database through GORM and accelerated with Redis hash caching to reduce query latency.", + "core_system_configuration_keys_6dc02be8": "Core system configuration keys:", + "platform_display_name_116219b1": ": platform display name", + "password_login_switch_153eaa45": ": password login switch", + "user_registration_switch_827b5b21": ": user registration switch", + "maximum_tokens_per_regular_user_default_5_5dd6d9e7": ": maximum tokens per regular user (default: 5)", + "5_background_tasks_and_scheduling_7142ce96": "5. Background tasks and scheduling", + "cobra_provides_separate_commands_so_worker_services_1bbae2df": "Cobra provides separate commands so worker services can handle expensive computation and slow I/O independently:", + "scheduled_dispatch_scheduler_command_e79062ad": "Scheduled dispatch (scheduler command):", + "enqueues_tasks_in_redis_according_to_their_694e5985": "Enqueues tasks in Redis according to their Cron expressions.", + "workers_with_queue_priorities_worker_command_faa970b1": "Workers with queue priorities (worker command):", + "asynq_consumes_tasks_by_priority_and_executes_69d82251": "Asynq consumes tasks by priority and executes them concurrently, such as temporary upload cleanup and external log synchronization.", + "6_distributed_tracing_and_structured_logs_e8971cad": "6. Distributed tracing and structured logs", + "the_platform_integrates_monitoring_components_for_observability_2b1da50f": "The platform integrates monitoring components for observability across distributed services:", + "tracing_context_is_propagated_automatically_gin_requests_0f549cce": "Tracing context is propagated automatically. Gin requests, outgoing requests, and GORM database operations carry spans for diagnosing latency and errors.", + "zap_structured_logs_1c9725e8": "Zap structured logs:", + "standardizes_backend_console_and_log_output_as_16970757": "Standardizes backend console and log output as JSON for integration with log collectors such as ELK and Loki.", + "last_updated_value0_87a896df": "Last updated: {value0}", + "back_to_home_d66f4359": "Back to home", + "sections_fcb2696e": " sections", + "last_updated_a83cd480": "Last updated:", + "copied_8f6f8d97": "Copied", + "copy_markdown_f9c71ee4": "Copy Markdown", + "table_of_contents_52daa71e": "Table of contents", + "questions_about_this_content_feb7d44a": "Questions about this content?", + "contact_support_4cac90c8": "Contact support", + "1_information_we_collect_af0db091": "1. Information we collect", + "we_collect_only_the_information_necessary_to_72f6e528": "We collect only the information necessary to provide this service, following the principles of lawfulness, fairness, and necessity. Collection is limited to the following:", + "1_1_authentication_information_60199ee9": "1.1 Authentication information:", + "when_you_register_locally_or_sign_in_acdd6bd1": "When you register locally or sign in through a third-party OIDC provider, we collect your username, linked email address, password hash, and associated third-party OpenID identifier.", + "we_do_not_require_a_phone_number_3540dfca": "We do not require a phone number, identity document, or other real-world identity information.", + "1_2_service_and_api_logs_46c9a04d": "1.2 Service and API logs:", + "for_system_security_and_auditing_we_automatically_89737fdd": "For system security and auditing, we automatically collect activity logs, including IP address, access date and time, personal access token API call history, and User-Agent (browser, device, or request tool).", + "2_data_storage_and_security_7cc5cdef": "2. Data storage and security", + "we_recognize_the_importance_of_data_security_cd3bd4d9": "We recognize the importance of data security and use industry-standard technical measures to protect your data:", + "storage_security_a33cfbef": "Storage security:", + "user_data_is_stored_in_a_dedicated_132af7bb": "User data is stored in a dedicated cloud database or local container storage accessible only to authorized system applications.", + "cryptography_9a15a88b": "Cryptography:", + "sensitive_password_and_personal_access_token_hashes_9f724cd8": "Sensitive password and personal access token hashes are stored using strong cryptographic algorithms. API data transport requires SSL/TLS encryption.", + "access_control_3471eafa": "Access control:", + "we_apply_the_principle_of_least_privilege_79328c28": "We apply the principle of least privilege. Platform data is not shared externally, and internal system administration is recorded in audit logs.", + "3_how_we_use_information_41b4d6ad": "3. How we use information", + "collected_information_is_used_only_for_the_4868b538": "Collected information is used only for the following purposes:", + "identification_481b07f4": "Identification:", + "to_identify_your_registered_account_and_display_8c559094": "To identify your registered account and display your profile and settings.", + "service_functions_c5834f20": "Service functions:", + "to_identify_and_process_api_authentication_requests_d48a36a2": "To identify and process API authentication requests using your personal access tokens.", + "security_e7132437": "Security:", + "to_analyze_ip_addresses_and_activity_logs_5210836a": "To analyze IP addresses and activity logs for API abuse and brute-force attacks, protecting backend stability.", + "prohibited_uses_b13fde94": "Prohibited uses:", + "we_will_8856fb95": "We will ", + "never_c1d7d603": "never", + "sell_your_data_to_third_parties_or_7bb4d77a": " sell your data to third parties or provide user profiling or advertising services to any organization.", + "4_information_sharing_and_disclosure_c034d295": "4. Information sharing and disclosure", + "4_1_sharing_principles_b89eb890": "4.1 Sharing principles:", + "we_do_not_share_personal_information_with_ffcf1e8f": "We do not share personal information with third parties, including affiliates and business partners, except in the following circumstances:", + "with_your_prior_explicit_authorization_or_consent_0fa05878": "With your prior explicit authorization or consent;", + "when_required_by_applicable_laws_legal_proceedings_cb547dd6": "When required by applicable laws, legal proceedings, or binding administrative or judicial demands.", + "4_2_transfer_and_public_disclosure_383d35be": "4.2 Transfer and public disclosure:", + "we_do_not_transfer_your_personal_information_e3339921": "We do not transfer your personal information to any company, organization, or individual, or publicly disclose it for commercial purposes.", + "5_your_rights_and_data_management_08999326": "5. Your rights and data management", + "under_the_personal_information_protection_law_of_94ea405e": "Under the Personal Information Protection Law of the People's Republic of China, you retain control over your personal information:", + "5_1_access_and_management_854d0793": "5.1 Access and management:", + "you_may_sign_in_at_any_time_8727c5be": "You may sign in at any time to view your basic personal information and generate, rotate, or revoke access tokens (API keys).", + "5_2_deletion_and_account_closure_9d1e7aba": "5.2 Deletion and account closure:", + "if_you_stop_using_the_service_you_24c642c6": "If you stop using the service, you may request account closure. We will promptly delete sensitive information from active storage, except security logs that must be retained by law.", + "6_policy_updates_and_notices_444076f5": "6. Policy updates and notices", + "this_privacy_policy_may_change_as_the_2a270464": "This privacy policy may change as the service develops or applicable laws change.", + "we_will_prominently_announce_material_changes_for_210fbd3b": "We will prominently announce material changes, for example through platform announcements or dialogs. Continued use of the service indicates acceptance of the revised policy.", + "1_agreement_and_service_overview_511c6782": "1. Agreement and service overview", + "1_1_parties_d2bf79cd": "1.1 Parties:", + "these_terms_of_service_the_agreement_govern_1e6754fc": "These Terms of Service (the Agreement) govern use of this system between you (the User or Developer) and the operator of this general-purpose development platform (the System or Platform).", + "1_2_read_carefully_dfde346e": "1.2 Read carefully:", + "this_system_is_a_general_purpose_full_f1d395fe": "This system is a general-purpose full-stack foundation for customization, providing registration, sessions, OIDC integration, API key authentication, and administration. Please read all terms before using it.", + "1_3_agreement_contents_f05cebc0": "1.3 Agreement contents:", + "the_agreement_includes_these_terms_and_any_43e244b6": "The Agreement includes these terms and any rules, statements, or explanations we have published or may publish. Those rules form an integral part of the Agreement and have equal legal effect.", + "2_service_definition_and_nature_61db87f8": "2. Service definition and nature", + "your_responsibility_527cbdbf": "Your responsibility:", + "the_entity_deploying_and_operating_a_customized_047c6a8f": "The entity deploying and operating a customized version of this system assumes all compliance responsibility for its business activities in other production environments.", + "3_registration_and_account_use_cfab1555": "3. Registration and account use", + "3_1_accounts_2631c114": "3.1 Accounts:", + "users_may_register_through_the_frontend_or_e527ef52": "Users may register through the frontend or link a login through a third-party OIDC provider enabled by an administrator.", + "3_2_password_and_token_security_80dd80d9": "3.2 Password and token security:", + "password_security_7a29e44e": "Password security:", + "keep_your_account_password_secure_eb9adf10": "Keep your account password secure.", + "api_token_security_34af066f": "API token security:", + "a_personal_access_token_api_key_represents_6215b3b0": "A personal access token (API key) represents your account's full API permissions.", + "you_are_responsible_for_data_loss_or_0c9acc88": "You are responsible for data loss or system damage resulting from a token leak caused by inadequate safekeeping.", + "4_prohibited_user_conduct_bd5628b6": "4. Prohibited user conduct", + "when_using_this_system_you_must_comply_1e54356c": "When using this system, you must comply with applicable laws, including the Cybersecurity Law of the People's Republic of China and the Measures for the Administration of Security Protection of Computer Information Networks Connected to the Internet.", + "the_following_activities_are_strictly_prohibited_5b57de7f": "The following activities are strictly prohibited:", + "endangering_national_security_4a160c4e": "Endangering national security:", + "opposing_constitutional_principles_endangering_national_security_disclosing_bdfb8814": "Opposing constitutional principles, endangering national security, disclosing state secrets, subverting state power, or undermining national unity;", + "illegal_information_services_c97748bb": "Illegal information services:", + "hacking_tools_ddos_attack_services_server_brute_686746c4": "Hacking tools, DDoS attack services, server brute-force services, or other illegal information service platforms;", + "pornography_gambling_and_drugs_fdb44a54": "Pornography, gambling, and drugs:", + "creating_copying_publishing_or_distributing_obscene_pornographic_ec8c0fdb": "Creating, copying, publishing, or distributing obscene, pornographic, gambling-related, violent, murderous, terrorist, or crime-inciting content;", + "intellectual_property_infringement_cc10b950": "Intellectual property infringement:", + "selling_pirated_software_or_media_illegal_game_3f0f4159": "Selling pirated software or media, illegal game cheats, unauthorized game servers, stolen accounts, or leaked personal databases;", + "other_unlawful_information_4085c17c": "Other unlawful information:", + "spreading_rumors_promoting_cults_or_superstition_insulting_a062981a": "Spreading rumors, promoting cults or superstition, insulting or defaming others, or infringing their lawful rights.", + "enforcement_9c7cfd68": "Enforcement:", + "if_these_rules_are_violated_the_platform_86df3b18": "If these rules are violated, the platform operator may, without notice, ", + "permanently_suspend_your_account_block_all_access_b9f04a42": "permanently suspend your account, block all access token requests, and refer relevant information to public security or cybersecurity authorities as required by law.", + "5_disclaimer_and_force_majeure_be649f43": "5. Disclaimer and force majeure", + "5_1_general_disclaimer_30d01f0d": "5.1 General disclaimer:", + "this_development_platform_is_provided_as_is_23bc2b55": "This development platform is provided as is and as available. We do not guarantee that it meets your particular development needs or provide additional guarantees of timeliness, security, or accuracy.", + "5_2_service_interruptions_16558fa8": "5.2 Service interruptions:", + "the_platform_is_not_liable_for_interruptions_405b1c95": "The platform is not liable for interruptions, data loss, or account damage caused by the following force majeure events:", + "natural_disasters_including_typhoons_earthquakes_tsunamis_and_1bbbfb21": "Natural disasters, including typhoons, earthquakes, tsunamis, and floods;", + "government_actions_cybersecurity_law_changes_or_administrative_2b7322f5": "Government actions, cybersecurity law changes, or administrative orders;", + "telecommunication_faults_or_damage_to_data_center_259a542f": "Telecommunication faults or damage to data center or submarine cables;", + "data_corruption_or_downtime_caused_by_hacking_68f2e04f": "Data corruption or downtime caused by hacking or ransomware.", + "6_governing_law_and_disputes_b9663c86": "6. Governing law and disputes", + "6_1_governing_law_ff2afc5e": "6.1 Governing law:", + "the_formation_performance_interpretation_and_resolution_of_c1510775": "The formation, performance, interpretation, and resolution of disputes under this Agreement are governed by ", + "the_laws_of_the_people_s_republic_5d5c80d6": "the laws of the People's Republic of China", + "excluding_the_conflict_of_laws_rules_of_b235c4e3": " (excluding the conflict-of-laws rules of Hong Kong, Macao, and Taiwan).", + "6_2_dispute_resolution_44e871de": "6.2 Dispute resolution:", + "disputes_should_first_be_resolved_through_friendly_1ccf4610": "Disputes should first be resolved through friendly negotiation. If negotiation fails, they must be submitted to a competent people's court at the location of the system's deployer or operator.", + "failed_to_load_value0_7f5a06a3": "Failed to load {value0}", + "load_failed_d1d04482": "Load failed", + "no_data_497c8569": "No data", + "loading_d04fcbda": "Loading", + "saved_e05ddf99": "Saved", + "save_failed_6309a3bb": "Save failed", + "unknown_error_13a46616": "Unknown error", + "deleted_5223f91b": "Deleted", + "delete_failed_c228558c": "Delete failed", + "configuration_list_53052147": "Configuration list", + "confirm_delete_2d61cf17": "Confirm delete?", + "this_cannot_be_undone_this_will_permanently_8c896756": "This cannot be undone. This will permanently delete the configuration.", + "cancel_2cd0f3be": "Cancel", + "confirm_delete_a3ea3c17": "Confirm delete", + "configuration_info_7f69a860": "Configuration info", + "select_a_configuration_to_view_details_ec4949ab": "Select a configuration to view details", + "updating_e1d9dd03": " Updating", + "update_3055a035": "Update", + "actions_ed31fbb4": "Actions", + "enter_password_728a7b60": "Enter password", + "for_your_account_security_please_enter_the_28fc0bbd": "For your account security, please enter the 6-digit verification password", + "verifying_6c128e0a": "Verifying…", + "confirm_36f33ada": "Confirm", + "profile_b94d13a4": "Profile", + "manage_your_account_information_and_preferences_cced83b4": "Manage your account information and preferences", + "user_guide_c83b6b0a": "User guide", + "api_integration_and_everyday_operation_guides_281269a1": "API integration and everyday operation guides", + "system_settings_68ea5dd4": "System settings", + "manage_registration_login_and_authentication_provider_security_f378ed37": "Manage registration, login, and authentication provider security", + "global_system_configuration_1eb8931e": "Global system configuration", + "manage_core_platform_runtime_settings_341e4182": "Manage core platform runtime settings", + "user_permissions_8e8b1d33": "User permissions", + "view_and_manage_registered_users_and_their_db533b7c": "View and manage registered users and their account status", + "background_task_scheduling_408172d1": "Background task scheduling", + "dispatch_and_monitor_background_and_scheduled_tasks_83967fc9": "Dispatch and monitor background and scheduled tasks", + "system_logs_366ada1d": "System logs", + "view_background_task_logs_and_system_status_24fd6a7d": "View background task logs and system status", + "a_modern_extensible_administration_platform_with_ready_0d95c0c1": "A modern, extensible administration platform with ready-to-use infrastructure for building enterprise applications.", + "get_started_715ee9dc": "Get started", + "hide_tip_738f4b27": "Hide tip", + "quick_navigation_05deefcb": "Quick navigation", + "open_fee3b5a5": "Open ", + "open_df42ebc3": "Open ", + "administration_console_ceb0c289": "Administration console", + "live_origin_error_page_preview_a2a12823": "Live origin error page preview", + "html_editor_19f0d7b7": "HTML editor", + "bytes_f5d87a8b": " bytes", + "leave_empty_for_the_built_in_default_29ffce9f": " · Leave empty for the built-in default", + "leave_empty_to_use_the_built_in_e57252bd": "Leave empty to use the built-in default template…", + "drag_to_resize_b5154511": "Drag to resize", + "live_preview_a76fdaad": "Live preview", + "preview_13d61fea": "Preview", + "for_example_github_1061e2e4": "For example: github", + "for_example_github_login_1e0c113c": "For example: GitHub login", + "https_or_leave_empty_a0d86d9b": "https://... or leave empty", + "missing_configuration_value0_31b43fa6": "Missing configuration: {value0}", + "missing_configuration_file_access_whitelist_6dd80936": "Missing configuration: file_access_whitelist", + "directory_visibility_configuration_json_string_format_url_8e3c0671": "Directory visibility configuration (JSON string, format: {url: enabled})", + "missing_configuration_login_session_ttl_hours_d6f433c2": "Missing configuration: login_session_ttl_hours", + "server_address_d3716cc5": "Server address", + "platform_display_name_4b656edf": "Platform display name", + "for_example_openflare_28e07341": "For example: OpenFlare", + "for_example_https_example_com_9cf31711": "For example: https://example.com", + "allow_search_engines_to_crawl_and_index_59eff8b5": "Allow search engines to crawl and index", + "for_example_smtp_example_com_834569b4": "For example: smtp.example.com", + "for_example_587_or_465_1bd015a5": "For example: 587 or 465", + "for_example_sender_example_com_1795f7c6": "For example: sender@example.com", + "for_example_receiver_example_com_ade0ff8b": "For example: receiver@example.com", + "for_example_login_email_c7212e1f": "For example: login_email", + "for_example_login_verification_email_9d28f166": "For example: Login verification email", + "for_example_openflare_login_verification_code_0925e8d4": "For example: OpenFlare login verification code", + "for_example_your_code_is_code_valid_44cf273f": "e.g. Verification email. Available variable: {{.Code}}", + "h3_message_title_h3_p_message_content_20c91d04": "

    Message title

    Message content; available variable: {{.Code}}

    ", + "register_a_user_curl_x_post_https_4ae682b0": "# Register a user\ncurl -X POST https://api.example.com/api/v1/auth/register \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"username\": \"developer\",\n \"email\": \"dev@example.com\",\n \"password\": \"secure_password\"\n }'", + "quick_start_42fbb067": "# Quick start", + "developer_friendly_40c2a4ce": "Developer friendly, ", + "quick_to_integrate_18cbee8a": "quick to integrate", + "standard_restful_apis_complete_typescript_types_detailed_6369a0e2": "Standard RESTful APIs, complete TypeScript types, detailed documentation, and Swagger UI. Integrate with just a few lines of code in your preferred language.", + "restful_api_with_clear_understandable_semantics_1fa9d6f5": "RESTful API with clear, understandable semantics", + "openapi_swagger_documentation_ccbcbc84": "OpenAPI / Swagger documentation", + "developer_friendly_typescript_type_definitions_910a7909": "Developer-friendly TypeScript type definitions", + "code_samples_and_integration_guides_9e998a5a": "Code samples and integration guides", + "an_open_source_platform_for_managing_edge_317d8f34": "An open-source platform for managing edge nodes and reverse proxies. Built with Go and React to manage OpenResty nodes, website rules, and WAF configuration.", + "product_aea82737": "Product", + "dashboard_0853bf3c": "Dashboard", + "personal_c2952a5f": "Personal", + "development_9ec42862": "Development", + "source_code_606a4e58": "Source code", + "community_5bcd0ddc": "Community", + "discussions_3cef912c": "Discussions", + "privacy_policy_e464b41f": "Privacy policy", + "terms_of_service_9303687a": "Terms of service", + "built_for_customization_ff3cf147": "Built for customization", + "a_modern_administration_platform_116688ff": "A modern administration platform", + "ready_to_use_infrastructure_that_is_easy_77776634": "Ready-to-use infrastructure that is easy to extend for enterprise applications.", + "get_started_d1f9c450": "Get started", + "learn_more_ca66c2da": "Learn more", + "ready_to_use_c58cfea2": "Ready to use", + "highly_extensible_9dfa6c10": "Highly extensible", + "production_infrastructure_08c3a5e6": "Production infrastructure", + "good_fortune_b3541887": "Luck", + "retry_b8784c8d": "Retry", + "an_unknown_error_occurred_please_try_again_79a9f98a": "An unknown error occurred. Please try again later.", + "view_error_details_9e7e4e37": "View error details", + "unknown_error_e83a8ca8": "Unknown error", + "fetching_data_9ac5e428": "Fetching data...", + "type_and_press_enter_to_add_e30b7b5f": "Type and press Enter to add", + "remove_value0_30c2158a": "Remove {value0}", + "fnv_1a_32_bit_function_fnv1a_str_a1690b96": "\n// FNV-1a 32-bit\nfunction fnv1a(str) {\n let h = 2166136261 >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619) >>> 0;\n }\n return h;\n}\nfunction fnv1aResume(state, str) {\n let h = state >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619) >>> 0;\n }\n return h;\n}\n\n// xorshift32 PRNG → hex string\nfunction prngFromHash(seed, len) {\n let s = seed >>> 0, r = '';\n while (r.length < len) {\n s ^= (s << 13) >>> 0;\n s ^= (s >>> 17) >>> 0;\n s ^= (s << 5) >>> 0;\n s = s >>> 0;\n r += s.toString(16).padStart(8, '0');\n }\n return r.slice(0, len);\n}\n\n// SHA-256 via SubtleCrypto (available in workers)\nasync function sha256Hex(input) {\n const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));\n return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Solve one puzzle\nasync function solvePuzzle(token, index, size, difficulty) {\n const tFnv = fnv1a(token);\n const saltSeed = fnv1aResume(tFnv, String(index + 1));\n const targetSeed = fnv1aResume(saltSeed, 'd');\n const salt = prngFromHash(saltSeed, size);\n const target = prngFromHash(targetSeed, difficulty);\n\n for (let nonce = 0; nonce < 10_000_000; nonce++) {\n if ((await sha256Hex(salt + nonce)).startsWith(target)) return nonce;\n }\n throw new Error('Could not solve puzzle ' + (index + 1) + ' within the allowed search range');\n}\n\n// Entry point — receive task from main thread\nself.onmessage = async ({ data }) => {\n const { token, count, size, difficulty } = data;\n try {\n const t0 = performance.now();\n const solutions = [];\n for (let i = 0; i < count; i++) {\n solutions.push(await solvePuzzle(token, i, size, difficulty));\n }\n const elapsed = ((performance.now() - t0) / 1000).toFixed(3);\n self.postMessage({ type: 'done', solutions, elapsed });\n } catch (err) {\n self.postMessage({ type: 'error', message: err.message });\n }\n};\n", + "worker_execution_failed_1877e35b": "Worker execution failed", + "failed_to_fetch_the_verification_challenge_95553133": "Failed to fetch the verification challenge", + "cap_starting_verification_pow_solver_657f3af5": "[Cap] Starting verification PoW solver", + "challenge_count_value0_ea1ffc80": " Challenge count: {value0}", + "difficulty_value0_f104cb0d": " Difficulty: {value0}", + "salt_size_value0_4f2bae5d": " Salt size: {value0}", + "cap_verification_pow_solver_completed_e1f79fd5": "[Cap] Verification PoW solver completed", + "worker_duration_value0_s_8de32777": " Worker duration: {value0}s", + "total_duration_including_communication_value0_s_004a6894": " Total duration (including communication): {value0}s", + "verification_failed_d3925ab2": "Verification failed", + "doctype_html_html_lang_en_head_meta_3e4f5ecb": "\n\n\n\n \n \n Offline page | OpenFlare\n \n\n\n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    OFFLINE FALLBACK
    \n

    Offline: contact the administrator

    \n

    \n The origin is temporarily unreachable. The offline Service Worker intercepted this request. Use the contact details below for support.\n

    \n
    \n
    \n Administrator email\n admin@example.com\n
    \n
    \n Service status\n status.example.com\n
    \n
    \n
    \n
    \n
    \n \n \n \n OpenFlare Service Worker\n
    \n OFFLINE MODE\n
    \n
    \n\n\n", + "minimal_white_5636e8a9": "Minimal white", + "line_topology_22ad450c": "Line topology", + "bauhaus_5cc69781": "Bauhaus", + "doctype_html_html_lang_en_head_meta_42a4f2da": "\n\n\n\n \n \n Offline page | OpenFlare\n \n\n\n
    \n
    \n
    \n \n SERVICE WORKER OFFLINE CACHE\n
    \n

    Network interrupted or origin unreachable

    \n

    \n Service Worker fallback protection is active. Contact the administrator through the following channels:\n

    \n
    \n
    \n Email Support\n admin@example.com\n
    \n
    \n Status Page\n status.example.com\n
    \n
    \n
    \n
    \n
    \n \n \n \n OpenFlare Edge\n
    \n FALLBACK ACTIVE\n
    \n
    \n\n\n", + "doctype_html_html_lang_en_head_meta_505c1308": "\n\n\n\n \n \n Offline page | OpenFlare\n \n\n\n
    \n
    OFFLINE CONTACT
    \n

    Website unavailable: contact the administrator

    \n

    \n The origin is currently unreachable. Service Worker offline protection is active. Contact the administrator using the details below.\n

    \n
    \n
    \n \n Contact email: admin@example.com\n
    \n
    \n \n Service status: status.example.com\n
    \n
    \n
    \n \n \n \n OpenFlare Protection\n
    \n
    \n\n\n", + "status_code_tag_must_not_be_empty_21235ebf": "Status code tag must not be empty", + "invalid_status_code_range_value0_8983e96e": "Invalid status code range: {value0}", + "invalid_status_code_value0_0f206e48": "Invalid status code: {value0}", + "status_code_range_endpoints_are_reversed_value0_d92c2784": "Status code range endpoints are reversed: {value0}", + "status_code_must_be_between_value0_value1_c5b04822": "Status code must be between {value0}–{value1}: {value2}", + "invalid_status_code_tag_d16e85d7": "Invalid status code tag", + "add_at_least_one_status_code_tag_b81b2696": "Add at least one status code tag", + "the_expanded_status_code_list_is_empty_47245f34": "The expanded status code list is empty", + "request_canceled_5892f9b1": "Request canceled", + "insufficient_permissions_complete_verification_and_try_again_73719073": "Insufficient permissions; complete verification and try again", + "the_requested_resource_was_not_found_aecc0681": "The requested resource was not found", + "request_parameter_validation_failed_60ef7c51": "Request parameter validation failed", + "too_many_requests_retry_in_value0_seconds_ab5e78f1": "Too many requests; retry in {value0, plural, one {# second} other {# seconds}}", + "request_rate_limit_ad4f0f8b": "Request rate limit", + "internal_server_error_try_again_later_4f0dd7e9": "Internal server error; try again later", + "cannot_connect_to_the_server_check_that_5551f4fc": "Cannot connect to the server; check that the backend is running", + "network_request_failed_8407ceab": "Network request failed", + "request_canceled_manually_7ba622e6": "Request canceled manually", + "all_requests_canceled_72f8fa9a": "All requests canceled", + "network_connection_failed_check_your_network_cbf47b07": "Network connection failed; check your network", + "request_timed_out_try_again_later_6c3465f6": "Request timed out; try again later", + "unauthorized_sign_in_first_466879f5": "Unauthorized; sign in first", + "insufficient_permissions_5e3c8a26": "Insufficient permissions", + "value0_must_be_a_valid_number_1d79f887": "{value0} must be a valid number", + "value0_must_be_true_or_false_23cce68e": "{value0} must be true or false", + "value0_must_not_be_empty_8bf3a929": "{value0} must not be empty", + "request_canceled_282c4cfb": "Request canceled", + "base64_encoding_is_not_supported_in_this_7642d848": "Base64 encoding is not supported in this environment", + "rate_limit_retry_later": "Too many requests; try again later", + "list_separator": ", ", + "sentence_end": ".", + "task_notification_template_example": "{\"title\":\"Task completed\",\"content\":\"{{task_name}}: {{task_status}}\",\"level\":\"INFO\"}", + "ui_totalRequests": "Total Requests", + "ui_totalVisits": "Total Visits", + "ui_bandwidthServed": "Bandwidth Served", + "ui_requestsOverTime": "Requests over time", + "ui_requestsByDevice": "Requests by device type", + "ui_statusCodes": "Status Codes", + "ui_topPaths": "Top Paths", + "ui_topHosts": "Top Hosts", + "ui_topIps": "Top IPs", + "ui_topBrowsers": "Top Browsers", + "ui_topOperatingSystems": "Top Operating System", + "ui_topUserAgents": "Top User-Agents", + "ui_deviceTypes": "Device Types", + "ui_fileHash": "Hash (SHA-256)", + "ui_pathStyle": "Path Style", + "ui_requestHeaders": "Request Headers", + "ui_barkApp": "Bark App", + "ui_administrator": "Admin", + "ui_nodeLabel": "Node:", + "ui_payload": "Payload", + "ui_selectAll": "Select all", + "ui_production": "Production", + "ui_allDeployments": "All deployments", + "ui_remoteUrl": "Remote URL", + "ui_githubRelease": "GitHub Release", + "ui_releaseTag": "Release tag", + "ui_originHostHeader": "Origin Host Header", + "ui_globalRuleGroup": "Global Rule Group", + "ui_zone": "Zone", + "ui_pageNotFound": "Page Not Found", + "ui_jsonBody": "JSON Body", + "ui_openTelemetryTracing": "OpenTelemetry Tracing:", + "ui_clientId": "Client ID", + "ui_clientSecret": "Client Secret", + "ui_scopes": "Scopes", + "ui_copyright": "© 2026 Modern Platform. All rights reserved.", + "ui_technologyStack": "Stack", + "ui_readyFor": "Ready for", + "ui_development": "Develop", + "ui_features": "Features", + "ui_modern": "Modern", + "ui_versionLabel": "Version", + "ui_builtAt": "Build At", + "ui_breadcrumb": "breadcrumb", + "ui_more": "More", + "ui_previousSlide": "Previous slide", + "ui_nextSlide": "Next slide", + "ui_closeLabel": "Close", + "ui_imageCrop": "crop", + "ui_pagination": "pagination", + "ui_previousPage": "Go to previous page", + "ui_previousLabel": "Previous", + "ui_nextPage": "Go to next page", + "ui_nextLabel": "Next", + "ui_morePages": "More pages", + "ui_sidebarLabel": "Sidebar", + "ui_mobileSidebarDescription": "Displays the mobile sidebar.", + "ui_toggleSidebar": "Toggle Sidebar", + "ui_loadingLabel": "Loading", + "origin_error_page_minimalist": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n

    {{status}}

    \n

    \n The upstream server is unreachable. Please try again later or contact the site administrator if the problem persists.\n

    \n

    {{host}}

    \n
    \n \n \n \n OpenFlare\n
    \n
    \n\n\n", + "origin_error_page_line_style": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n
    \n
    {{status}}
    \n
    Origin Unavailable
    \n
    \n\n
    \n \n \n \n \n \n \n \n \n \n\n \n \n\n \n CLI\n CLIENT\n REQ_SENT\n\n \n \n OPENFLARE\n EDGE_PROXY\n\n \n \n \n ORIGIN\n UNAVAILABLE\n \n
    \n\n
    \n \n \n \n OpenFlare\n
    \n
    \n\n \n\n\n", + "origin_error_page_baohaus": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n
    \n
    \n
    {{status}}
    \n\n
    \n \n \n \n OpenFlare\n
    \n
    \n\n
    \n

    Origin Unavailable

    \n

    \n The server received an invalid response from the upstream server.\n

    \n Form follows function: The connection path is broken. Please verify the origin server status.\n

    \n
    \n
    ERR_CODE: {{status}}
    \n
    HOST: {{host}}
    \n
    PROXY: OPENFLARE_EDGE
    \n
    --:--:-- UTC
    \n
    \n
    \n\n
    \n
    \n
    \n
    \n
    \n
    \n\n \n\n\n", + "ui_accountId": "Account ID", + "ui_endpointLabel": "Endpoint", + "ui_regionLabel": "Region", + "ui_bucketLabel": "Bucket", + "ui_accessKeyId": "Access Key ID", + "ui_secretAccessKey": "Secret Access Key", + "ui_isoCode": "ISO Code", + "ui_serverUrl": "Server URL", + "ui_commandPalette": "Command palette", + "ui_commandSearch": "Search for a command to run…", + "ui_botLabel": "Bot", + "ui_unknownLabel": "Unknown", + "ui_otherLabel": "Other", + "ui_toastRegion": "Notifications", + "ui_closeToast": "Close notification", + "node_type_edge": "Edge", + "node_type_relay": "Relay", + "node_type_tunnel": "Tunnel", + "system_metric_day": "{count}d", + "system_metric_hour": "{count}h", + "system_metric_minute": "{count}m", + "system_metric_second": "{count}s", + "system_metric_unknown": "Unknown", + "system_metric_none": "None" + } +} diff --git a/frontend/messages/fragments/runtime.ru.json b/frontend/messages/fragments/runtime.ru.json new file mode 100644 index 00000000..fa945d3d --- /dev/null +++ b/frontend/messages/fragments/runtime.ru.json @@ -0,0 +1,575 @@ +{ + "runtime": { + "api_documentation_cb3d2229": "Документация API", + "openflare_provides_a_simple_powerful_api_for_ba400468": "OpenFlare предоставляет простой и мощный API для ряда языков программирования и сред разработки. Его стандартный интерфейс RESTful упрощает интеграцию и настройку серверной части.", + "platform_user_guide_d0ead991": "Руководство пользователя платформы", + "welcome_to_the_openflare_management_platform_this_e8ecbe4d": "Добро пожаловать на платформу управления OpenFlare. В этом руководстве представлены основные компоненты, роли и разрешения, а также рабочий процесс настройки, которые помогут вам начать работу.", + "privacy_policy_b6da1ca6": "Политика конфиденциальности", + "we_value_your_privacy_this_policy_explains_2c9e3c0b": "Мы ценим вашу конфиденциальность. Эта политика объясняет, как мы собираем, используем и храним вашу личную информацию. ", + "and_the_rights_you_have_over_it_4560abf2": "и права, которые вы имеете на это.", + "terms_of_service_4e80a140": "Условия обслуживания", + "please_read_the_following_terms_carefully_to_c46a6a61": "Пожалуйста, внимательно прочитайте следующие условия, чтобы понять свои права.", + "use_1b3d699e": ": использовать ", + "or_07d4e3c0": "(или ", + "place_the_lucide_icon_directly_inside_the_104f794c": ": поместите значок Lucide прямо внутри контейнера заголовка и примените ", + "use_only_5e12eecb": ": использовать только ", + "for_pages_with_multiple_tabs_keep_each_5f721959": ": для страниц с несколькими вкладками содержимое каждой вкладки должно храниться в отдельном файле компонента (например, ", + "beyond_tabs_when_a_page_exceeds_600_d152cfdc": ": когда страница превышает 600 ", + "message_18ab4763": ") ", + "and_de1d5dd0": " и ", + "inputs_fabee312": "Входы (", + "and_selectors_e4875cdf": ") и селекторы (", + "should_share_the_compact_height_f1fe2218": ") должен иметь компактную высоту ", + "with_font_size_e7c29991": ", с размером шрифта ", + "and_use_ca398a12": "и использовать ", + "to_remove_overlapping_native_shadows_dd65b475": " чтобы удалить перекрывающиеся собственные тени.", + "align_checkboxes_toward_the_top_left_using_092dcef0": "Выровняйте флажки в левом верхнем углу, используя контейнер с ", + "add_bf15da3a": ". Добавить ", + "to_the_checkbox_so_multiline_descriptions_stay_1bb85c4b": " установите флажок, чтобы многострочные описания оставались выровненными вверху.", + "use_the_default_primary_or_secondary_db7513e5": "Используйте основной или дополнительный по умолчанию ", + "button_for_forms_and_create_save_actions_a657dba8": " кнопка для форм и действий создания/сохранения с компактной высотой ", + "outline_buttons_must_include_the_66ace005": ". Кнопки структуры должны включать ", + "class_43eff5ff": " класс.", + "1_align_label_and_input_32d4558d": "// 1. Align Label and Input", + "div_classname_space_y_1_5_label_32230ad7": "
    \n \n \n
    ", + "2_align_checkbox_to_the_top_bdf68ad8": "// 2. Align Checkbox to the top", + "div_classname_flex_items_start_gap_2_ccf0d526": "
    \n \n
    \n \n

    Multiline description

    \n
    \n
    ", + "card_classname_border_dashed_shadow_none_cardheader_35727acf": "\n \n Metric title\n \n \n \n
    42.8 %
    \n \n

    Status description

    \n
    \n
    ", + "div_classname_border_border_dashed_shadow_none_b430b520": "
    \n \n \n \n ID\n Status\n \n \n \n \n ID-1001\n \n \n \n Success\n \n \n \n \n
    \n
    ", + "disabled_f95ea7f4": "Отключено", + "title_administrator_login_alert_content_administrator_user_6f32cf7c": "{\"title\": \"Вход администратора\", \"content\": \"Администратор {{user.username}} ...\", \"level\": \"INFO\"}", + "lark_webhook_c2e0b312": "Lark Вебхук", + "title_content_view_details_url_7af4b7d5": "### $title\n$content\n\n[Подробнее]($url)", + "dingtalk_group_bot_5d8efec9": "DingTalk групповой бот", + "wecom_group_bot_138ea7a8": "WeCom групповой бот", + "under_development_78c33fbf": "В разработке", + "this_feature_is_being_migrated_and_developed_61ebaf78": "Эта функция переносится и развивается. Пожалуйста, зайдите позже.", + "port_for_the_built_in_frps_web_cfab540f": "Порт для встроенного интерфейса веб-администрирования FRPS", + "request_failed_please_try_again_later_8977b79a": "Запрос не выполнен. Пожалуйста, повторите попытку позже.", + "operation_failed_0c3b4cf7": "Операция не удалась", + "automatic_configuration_must_be_a_json_object_90ba17a4": "Автоматическая конфигурация должна быть объектом JSON.", + "high_frequency_404_scan_from_one_ip_f7d27ffb": "Высокочастотное сканирование 404 с одного IP", + "abnormal_direct_access_from_one_ip_732dbeee": "Аномальный прямой доступ с одного IP", + "permanent_3e71ccc8": "Постоянный", + "expired_2fe0e333": "Срок действия истек", + "in_value0_minutes_fd2a5b53": "Через {value0} мин", + "in_value0_hours_5ca37144": "Через {value0} ч", + "node_2410d860": "Узел", + "edge_099db313": "Ребро", + "edge_s_id_74f6affc": "Edge(?:\\s+ID)?", + "value0_s_value1_s_40960c98": "{value0}\\s+{value1}(?=$|[\\s,.:])", + "rule_graph_schema_version_must_be_1_ea9a1161": "График правил schema_version должен быть равен 1.", + "rule_graph_exceeds_the_size_limit_337651b9": "График правил превышает ограничение по размеру", + "node_id_must_not_be_empty_51a5de58": "Узел ID не должен быть пустым.", + "duplicate_node_id_value0_621230af": "Повторяющийся ID узла: {value0}", + "edge_id_must_not_be_empty_bc8f69cb": "Край ID не должен быть пустым.", + "duplicate_edge_id_value0_0a9d1c7c": "Повторяющийся ID ребра: {value0}", + "the_start_node_must_not_have_incoming_50a07b17": "Начальный узел не должен иметь входящих ребер.", + "the_rule_graph_must_contain_exactly_one_7dfbc027": "Граф правил должен содержать ровно один начальный узел.", + "the_rule_graph_must_contain_exactly_one_0036eb82": "Граф правил должен содержать ровно один разрешающий узел.", + "edge_value0_has_an_invalid_endpoint_or_9d84eb7e": "Ребро {value0} содержит неверный узел подключения или порт.", + "node_value0_output_value1_is_not_connected_c3038e81": "Выход {value1} узла {value0} не подключён", + "node_value0_output_value1_can_only_be_903e54ce": "Выход {value1} узла {value0} можно подключить только один раз.", + "node_value0_is_unreachable_from_the_start_086531dc": "Узел {value0} недоступен из начального узла.", + "node_value0_cannot_reach_a_terminal_node_8de2ff6e": "Узел {value0} не может достичь конечного узла.", + "the_rule_graph_must_not_contain_cycles_fd6fe797": "Граф правил не должен содержать циклов", + "node_value0_contains_an_invalid_ip_address_1873ab94": "Узел {value0} содержит неверный адрес IP.", + "node_value0_contains_an_invalid_cidr_00c880d9": "Узел {value0} содержит недопустимый CIDR.", + "node_value0_contains_an_invalid_ip_group_589219a5": "Узел {value0} содержит недопустимую группу IP.", + "node_value0_contains_an_invalid_geography_code_1a9f469b": "Узел {value0} содержит неверный географический код.", + "node_value0_has_pow_settings_outside_the_9fd2a4cb": "У узла {value0} настройки PoW выходят за пределы допустимого диапазона.", + "node_value0_has_invalid_block_response_settings_ae4c35ca": "Узел {value0} имеет неверные настройки ответа на блокировку.", + "node_value0_match_mode_must_be_and_242c1ae7": "Режим сопоставления узла {value0} должен быть and или or", + "node_value0_contains_an_invalid_browser_tag_4d0e6740": "Узел {value0} содержит недопустимый тег браузера.", + "node_value0_contains_an_invalid_operating_system_45bea1fb": "Узел {value0} содержит недопустимый тег операционной системы.", + "node_value0_allows_at_most_32_custom_497938c6": "Узел {value0} позволяет использовать не более 32 пользовательских шаблонов UA.", + "node_value0_custom_ua_pattern_must_not_a72718be": "Пользовательский шаблон узла {value0} UA не должен быть пустым.", + "node_value0_custom_ua_pattern_is_too_c2c83c0e": "Пользовательский шаблон узла {value0} UA слишком длинный.", + "node_value0_has_an_invalid_custom_ua_c47ffd0d": "Узел {value0} имеет недопустимый пользовательский шаблон UA.", + "node_value0_requires_at_least_one_pattern_6114bb93": "Узел {value0} требует хотя бы один шаблон, если включена пользовательская блокировка UA.", + "start_d2bb025a": "Начало", + "ip_match_43f4b6d4": "Проверка IP", + "geography_match_9acb08c4": "Проверка географии", + "ua_check_b56dac23": "Проверка User-Agent", + "security_checks_e09822e6": "Проверки безопасности", + "pow_challenge_3c043088": "Проверка PoW", + "allow_1e9f2561": "Разрешить", + "block_2a366d09": "Заблокировать", + "wechat_81b430b6": "WeChat", + "wildcard_c6b0739e": "Подстановочный знак", + "exact_match_391fe725": "Точное совпадение", + "value0_expires_value1_7d8600f8": "{value0} (срок действия: {value1})", + "sorry_the_page_you_requested_seems_to_ba66cd83": "Запрошенная вами страница не найдена.", + "check_the_path_or_return_to_safety_b1f31888": "Проверьте URL или вернитесь на панель управления.", + "previous_c9b9ae7a": "Назад", + "home_203c08e0": "Главная", + "1_api_conventions_and_authentication_e6d4df61": "1. API соглашения и аутентификация", + "standard_api_request_conventions_and_developer_access_39eb956e": "Стандартные соглашения о запросах API и проверка подлинности токена доступа разработчика.", + "1_1_standard_response_format_5cddae2f": "1.1 Стандартный формат ответа", + "all_api_endpoints_use_a_standard_json_87c92fb0": "Все конечные точки API используют стандартную структуру ответа JSON:", + "field_49ecd0e3": "Поле", + "type_ba40014f": "Тип", + "description_4262c45d": "Описание", + "error_message_empty_on_success_contains_error_d89d457f": "Сообщение об ошибке. Пусто (\"\") в случае успеха; содержит сведения об ошибке при сбое.", + "the_response_payload_null_when_the_request_97b8b29c": "Параметры ответа. null, если запрос не выполнен или данные не возвращаются.", + "successful_response_example_8917e853": "Пример успешного ответа:", + "failed_response_example_eae899e1": "Пример неудачного ответа:", + "error_msg_incorrect_password_data_null_b2eccc86": "{\n \"error_msg\": \"Неверный пароль\",\n \"data\": null\n}", + "1_2_authentication_methods_3c5db38a": "1.2 Методы аутентификации", + "protected_endpoints_require_credentials_except_public_endpoints_1fa0a24c": "Аутентификация требуется, за исключением общедоступных конечных точек, таких как вход в систему, регистрация и общедоступная конфигурация:", + "session_credentials_de72024d": "Учетные данные сеанса:", + "browser_clients_can_stay_signed_in_using_f316279b": "Клиенты браузера могут оставаться в системе, используя стандартный файл cookie сеанса.", + "access_tokens_8de23550": "Токены доступа:", + "for_backend_calls_and_third_party_integrations_dfd8bf0f": "Для бэкэнд-вызовов и сторонних интеграций. Создайте токен доступа API и включите его в заголовок запроса для аутентификации.", + "use_either_of_the_following_request_headers_1e03b04b": "Используйте любой из следующих заголовков запроса:", + "2_user_and_authentication_endpoints_4a89d429": "2. Конечные точки пользователя и аутентификации", + "2_1_user_registration_4f332f85": "2.1 Регистрация пользователя", + "endpoint_79a2369a": "Конечная точка:", + "description_3e633a20": "Описание:", + "register_a_local_account_when_registration_is_938891dc": "Зарегистрируйте локальную учетную запись, если регистрация включена в настройках администрирования.", + "parameter_9634fb08": "Параметр", + "required_11da9dc4": "Требуется", + "yes_b5141d3d": "Да", + "username_must_be_unique_and_contain_no_e81c1e5c": "Имя пользователя; должно быть уникальным и не содержать пробелов.", + "password_at_least_8_characters_37cdc807": "Пароль; не менее 8 символов.", + "no_0c70665b": "Нет", + "nickname_defaults_to_the_username_when_omitted_676aae7e": "Никнейм. По умолчанию используется имя пользователя, если оно опущено.", + "2_2_password_login_271483ba": "2.2 Вход по паролю", + "authenticate_with_a_username_and_password_establishing_a38ff252": "Аутентификация с использованием имени пользователя и пароля, создание файла cookie сеанса в случае успеха.", + "username_1a3f0617": "Имя пользователя", + "password_a621ab60": "Пароль", + "2_3_sign_out_3252611d": "2.3 Выйти", + "delete_the_current_session_cookie_and_sign_c213ffd9": "Удалите файл cookie текущего сеанса и выйдите из системы.", + "2_4_get_profile_7b96110a": "2.4 Получить профиль", + "get_the_signed_in_account_s_basic_b0eb8cfa": "Получите основную информацию об учетной записи, в которую выполнен вход, включая ID, роль и псевдоним.", + "3_personal_access_token_endpoints_989f5d7c": "3. Конечные точки токена личного доступа", + "access_token_management_requires_a_session_login_42d91543": "Управление токенами доступа требует входа в систему и доступно обычным пользователям.", + "3_1_list_tokens_9840c227": "3.1 Список токенов", + "list_the_current_user_s_tokens_with_c34d2210": "Вывести список токенов текущего пользователя с замаскированными секретными значениями.", + "3_2_create_an_access_token_e53bd784": "3.2 Создайте токен доступа", + "parameters_ba89685c": "Параметры:", + "name_token_name_is_admin_false_4f3e4868": "{\"name\": \"название токена\", \"is_admin\": false}", + "generate_an_access_token_the_response_includes_70552390": "Создайте токен доступа. Ответ включает значение открытого текста один раз; храните его в надежном месте.", + "optional_default_210b9d8f": " (необязательно, по умолчанию ", + "grant_administrator_privileges_to_the_token_only_2292cad8": "): предоставить токену права администратора. Только администраторы могут включить это. Обычные токены не могут получить доступ ", + "endpoints_815c88e5": " конечные точки.", + "successful_response_example_7783a39c": "Пример успешного ответа:", + "3_3_revoke_delete_a_token_ae510f58": "3.3 Отзыв/удаление токена", + "permanently_delete_a_token_by_id_immediately_145287f6": "Окончательно удалите токен до ID, немедленно сделав его недействительным.", + "3_4_rotate_a_token_secret_79209983": "3.4 Замена секрета токена", + "replace_a_token_s_secret_and_return_d2caca89": "Замените секрет токена и верните его новое значение в виде открытого текста. Предыдущий секрет становится недействительным; его имя и ID остаются прежними.", + "4_public_configuration_and_administration_17ebdbd5": "4. Публичная настройка и администрирование", + "4_1_public_system_configuration_2be912e4": "4.1 Конфигурация общедоступной системы", + "get_publicly_visible_configuration_keys_and_values_bc7bff66": "Получите общедоступные ключи конфигурации и значения для динамического рендеринга интерфейса.", + "response_example_d3087445": "Пример ответа:", + "4_2_system_configuration_crud_administrator_2ce66c8b": "4.2 Конфигурация системы CRUD (администратор)", + "manage_system_configs_dynamically_through_an_administrator_acdcdb99": "Управляйте system_configs динамически через сеанс администратора.", + "list_configuration_3d58680b": "Конфигурация списка:", + "create_a_configuration_entry_1d995264": "Создайте запись конфигурации:", + "update_a_configuration_value_2a0b0b76": "Обновите значение конфигурации:", + "delete_a_configuration_entry_1b77a6ea": "Удалить запись конфигурации:", + "1_quick_start_ef8fbb58": "1. Быстрый старт", + "a_guide_to_the_general_purpose_full_9cd8c987": "Руководство по универсальной полнофункциональной платформе разработки.", + "architecture_e992b65c": "Архитектура:", + "go_gin_gorm_redis_asynq_backend_react_d5fe02d3": "Go (Gin + GORM + Redis + Asynq) бэкэнд + React (Next.js 16 + Tailwind CSS 4 + shadcn/ui) интерфейс", + "authentication_d36b345b": "Аутентификация:", + "local_username_password_registration_and_login_plus_583ab638": "Локальная регистрация имени пользователя/пароля и вход в систему, а также привязка сторонней учетной записи OIDC (OAuth2)", + "access_tokens_b7b856d3": "Токены доступа:", + "personal_developer_access_tokens_api_keys_for_4b407149": "Токены доступа персонального разработчика (ключи API) для вызова системных API через заголовки HTTP.", + "observability_ea0cc1bf": "Наблюдаемость:", + "zap_structured_logging_and_opentelemetry_distributed_tracing_9fd6da9f": "Структурированное ведение журнала Zap и распределенная трассировка OpenTelemetry", + "2_authentication_and_security_settings_76967059": "2. Настройки аутентификации и безопасности", + "the_platform_combines_authentication_methods_to_support_bc9f1772": "Платформа сочетает в себе методы аутентификации для поддержки различных развертываний и вариантов использования:", + "2_1_username_and_password_authentication_97184f8f": "2.1 Аутентификация имени пользователя и пароля", + "registration_and_password_login_c5bd6cb4": "Регистрация и пароль для входа:", + "users_can_register_and_create_sessions_with_21883e04": "Пользователи могут регистрироваться и создавать сеансы с именем пользователя и паролем. Пароли хранятся с помощью ", + "salted_hashing_dc2a9b63": " хеширование с солью.", + "system_switches_53741557": "Системные переключатели:", + "administrators_can_disable_password_registration_or_login_a2648e62": "Администраторы могут в любое время отключить регистрацию пароля или вход в систему, чтобы потребовать стороннюю аутентификацию.", + "2_2_third_party_oidc_providers_f14083c8": "2.2 Сторонние поставщики OIDC", + "users_can_link_external_accounts_from_their_c08d3f5a": "Пользователи могут связать внешние учетные записи со страницы своего профиля:", + "open_4540d74d": "Открыть ", + "settings_profile_32835b0d": "Настройки/Профиль", + "message_a173cce8": ".", + "under_linked_accounts_view_existing_connections_or_5151eb9b": "В разделе «Связанные учетные записи» просмотрите существующие подключения или выберите доступного поставщика OIDC, чтобы начать процесс связывания OAuth2.", + "after_linking_use_the_oidc_login_button_6095b15d": "После привязки воспользуйтесь кнопкой входа OIDC на странице входа для быстрого доступа.", + "3_integrating_personal_access_tokens_af2647b5": "3. Интеграция токенов личного доступа", + "personal_access_tokens_allow_developers_and_third_9a242a0e": "Токены личного доступа позволяют разработчикам и сторонним инструментам напрямую вызывать системные API.", + "3_1_token_generation_and_storage_2b78c681": "3.1 Генерация и хранение токенов", + "one_time_plaintext_display_fd13912a": "Одноразовое отображение открытого текста:", + "a_newly_generated_plaintext_token_such_as_57eb11a1": "Недавно сгенерированный токен открытого текста (например, ", + "is_displayed_only_once_copy_and_save_8d378c8a": ") отображается только один раз. Скопируйте и немедленно сохраните его; его невозможно получить после закрытия диалога.", + "secure_hash_storage_1ce392ff": "Безопасное хранение хеша:", + "only_a_sha_256_hash_of_the_dbf434b1": "В базе данных хранится только хеш токена SHA-256, что предотвращает восстановление открытого текста из утекшего дайджеста.", + "3_2_calling_apis_with_headers_fccd7b0d": "3.2 Вызов API с заголовками", + "use_your_saved_plaintext_token_with_either_3af53a05": "Используйте сохраненный токен открытого текста с любым из следующих заголовков аутентификации HTTP:", + "method_1_authorization_bearer_header_2a9c2a6c": "Метод 1: заголовок носителя авторизации", + "method_2_custom_x_access_token_header_4548169f": "Способ 2: Пользовательский заголовок X-Access-Token", + "4_dynamic_system_configuration_b3bf8c49": "4. Динамическая конфигурация системы", + "the_built_in_key_value_configuration_module_713f6d05": "Встроенный модуль конфигурации «ключ/значение» позволяет администраторам динамически настраивать поведение во время выполнения:", + "cached_reads_35828322": "В кэше читается:", + "configuration_is_read_from_the_database_through_3e9f6a34": "Конфигурация считывается из базы данных через GORM и ускоряется с помощью хэш-кэширования Redis для уменьшения задержки запроса.", + "core_system_configuration_keys_6dc02be8": "Ключи конфигурации основной системы:", + "platform_display_name_116219b1": ": отображаемое имя платформы", + "password_login_switch_153eaa45": ": переключатель входа в систему с паролем", + "user_registration_switch_827b5b21": ": переключатель регистрации пользователя", + "maximum_tokens_per_regular_user_default_5_5dd6d9e7": ": максимальное количество токенов на обычного пользователя (по умолчанию: 5)", + "5_background_tasks_and_scheduling_7142ce96": "5. Фоновые задачи и планирование", + "cobra_provides_separate_commands_so_worker_services_1bbae2df": "Cobra предоставляет отдельные команды для независимого выполнения тяжёлых вычислений и медленных операций ввода-вывода:", + "scheduled_dispatch_scheduler_command_e79062ad": "Запланированная отправка (команда планировщика):", + "enqueues_tasks_in_redis_according_to_their_694e5985": "Ставит задачи в очередь Redis в соответствии с их выражениями Cron.", + "workers_with_queue_priorities_worker_command_faa970b1": "Воркеры с приоритетами очереди (рабочая команда):", + "asynq_consumes_tasks_by_priority_and_executes_69d82251": "Asynq использует задачи по приоритету и выполняет их одновременно, например очистку временной загрузки и синхронизацию внешнего журнала.", + "6_distributed_tracing_and_structured_logs_e8971cad": "6. Распределенная трассировка и структурированные журналы.", + "the_platform_integrates_monitoring_components_for_observability_2b1da50f": "Платформа объединяет компоненты мониторинга для обеспечения наблюдения за распределенными сервисами:", + "tracing_context_is_propagated_automatically_gin_requests_0f549cce": "Контекст трассировки передаётся автоматически. Запросы Gin, исходящие запросы и операции GORM содержат спаны для диагностики задержек и ошибок.", + "zap_structured_logs_1c9725e8": "Структурированные журналы Zap:", + "standardizes_backend_console_and_log_output_as_16970757": "Стандартизирует серверную консоль и вывод журнала как JSON для интеграции со сборщиками журналов, такими как ELK и Loki.", + "last_updated_value0_87a896df": "Последнее обновление: {value0}", + "back_to_home_d66f4359": "Вернуться домой", + "sections_fcb2696e": " разделы", + "last_updated_a83cd480": "Последнее обновление:", + "copied_8f6f8d97": "Скопировано", + "copy_markdown_f9c71ee4": "Копировать Markdown", + "table_of_contents_52daa71e": "Оглавление", + "questions_about_this_content_feb7d44a": "Есть вопросы об этом контенте?", + "contact_support_4cac90c8": "Связаться со службой поддержки", + "1_information_we_collect_af0db091": "1. Информация, которую мы собираем", + "we_collect_only_the_information_necessary_to_72f6e528": "Мы собираем только ту информацию, которая необходима для предоставления этой услуги, следуя принципам законности, справедливости и необходимости. Сбор ограничен следующим:", + "1_1_authentication_information_60199ee9": "1.1 Информация для аутентификации:", + "when_you_register_locally_or_sign_in_acdd6bd1": "Когда вы регистрируетесь локально или входите в систему через стороннего поставщика OIDC, мы собираем ваше имя пользователя, связанный адрес электронной почты, хэш пароля и связанный сторонний идентификатор OpenID.", + "we_do_not_require_a_phone_number_3540dfca": "Нам не требуется номер телефона, документ, удостоверяющий личность, или другая реальная идентификационная информация.", + "1_2_service_and_api_logs_46c9a04d": "1.2 Сервисные и API логи:", + "for_system_security_and_auditing_we_automatically_89737fdd": "В целях безопасности и аудита системы мы автоматически собираем журналы активности, включая адрес IP, дату и время доступа, токен личного доступа API историю вызовов и User-Agent (браузер, устройство или инструмент запроса).", + "2_data_storage_and_security_7cc5cdef": "2. Хранение и безопасность данных", + "we_recognize_the_importance_of_data_security_cd3bd4d9": "Мы осознаем важность безопасности данных и используем стандартные технические меры для защиты ваших данных:", + "storage_security_a33cfbef": "Безопасность хранения:", + "user_data_is_stored_in_a_dedicated_132af7bb": "Пользовательские данные хранятся в выделенной облачной базе данных или локальном контейнерном хранилище, доступном только авторизованным системным приложениям.", + "cryptography_9a15a88b": "Криптография:", + "sensitive_password_and_personal_access_token_hashes_9f724cd8": "Хэши конфиденциальных паролей и токенов личного доступа хранятся с использованием надежных криптографических алгоритмов. Для передачи данных API требуется шифрование SSL/TLS.", + "access_control_3471eafa": "Контроль доступа:", + "we_apply_the_principle_of_least_privilege_79328c28": "Мы применяем принцип наименьших привилегий. Данные платформы не передаются извне, а внутреннее администрирование системы записывается в журналы аудита.", + "3_how_we_use_information_41b4d6ad": "3. Как мы используем информацию", + "collected_information_is_used_only_for_the_4868b538": "Собранная информация используется только для следующих целей:", + "identification_481b07f4": "Идентификация:", + "to_identify_your_registered_account_and_display_8c559094": "Для идентификации вашей зарегистрированной учетной записи и отображения вашего профиля и настроек.", + "service_functions_c5834f20": "Функции сервиса:", + "to_identify_and_process_api_authentication_requests_d48a36a2": "Для идентификации и обработки запросов аутентификации API с использованием ваших личных токенов доступа.", + "security_e7132437": "Безопасность:", + "to_analyze_ip_addresses_and_activity_logs_5210836a": "Для анализа адресов IP и журналов активности на предмет злоупотреблений API и атак методом перебора, защищая стабильность серверной части.", + "prohibited_uses_b13fde94": "Запрещенное использование:", + "we_will_8856fb95": "Мы ", + "never_c1d7d603": "никогда не будем", + "sell_your_data_to_third_parties_or_7bb4d77a": " продавать ваши данные третьим лицам или предоставлять каким-либо организациям услуги профилирования пользователей или рекламных рассылок.", + "4_information_sharing_and_disclosure_c034d295": "4. Обмен и раскрытие информации", + "4_1_sharing_principles_b89eb890": "4.1 Принципы обмена:", + "we_do_not_share_personal_information_with_ffcf1e8f": "Мы не передаем личную информацию третьим лицам, включая аффилированные лица и деловых партнеров, за исключением следующих обстоятельств:", + "with_your_prior_explicit_authorization_or_consent_0fa05878": "С вашего предварительного явного разрешения или согласия;", + "when_required_by_applicable_laws_legal_proceedings_cb547dd6": "Когда этого требуют применимые законы, судебные разбирательства или обязательные административные или судебные требования.", + "4_2_transfer_and_public_disclosure_383d35be": "4.2 Передача и публичное раскрытие:", + "we_do_not_transfer_your_personal_information_e3339921": "Мы не передаем вашу личную информацию какой-либо компании, организации или частному лицу и не раскрываем ее публично в коммерческих целях.", + "5_your_rights_and_data_management_08999326": "5. Ваши права и управление данными", + "under_the_personal_information_protection_law_of_94ea405e": "В соответствии с Законом о защите личной информации Китайской Народной Республики вы сохраняете контроль над своей личной информацией:", + "5_1_access_and_management_854d0793": "5.1 Доступ и управление:", + "you_may_sign_in_at_any_time_8727c5be": "Вы можете войти в систему в любое время, чтобы просмотреть свою основную личную информацию и сгенерировать, заменить или отозвать токены доступа (ключи API).", + "5_2_deletion_and_account_closure_9d1e7aba": "5.2 Удаление и закрытие аккаунта:", + "if_you_stop_using_the_service_you_24c642c6": "Если вы прекратите пользоваться услугой, вы можете запросить закрытие учетной записи. Мы незамедлительно удалим конфиденциальную информацию из активного хранилища, за исключением журналов безопасности, которые должны храниться по закону.", + "6_policy_updates_and_notices_444076f5": "6. Обновления и уведомления политики", + "this_privacy_policy_may_change_as_the_2a270464": "Эта политика конфиденциальности может меняться по мере развития сервиса или изменения действующего законодательства.", + "we_will_prominently_announce_material_changes_for_210fbd3b": "Мы будем публично объявлять о существенных изменениях, например, посредством объявлений на платформе или диалогов. Продолжение использования услуги означает принятие пересмотренной политики.", + "1_agreement_and_service_overview_511c6782": "1. Соглашение и обзор услуг", + "1_1_parties_d2bf79cd": "1.1 Стороны:", + "these_terms_of_service_the_agreement_govern_1e6754fc": "Настоящие Условия обслуживания (Соглашение) регулируют использование данной системы между вами (Пользователем или Разработчиком) и оператором этой универсальной платформы разработки (Системы или Платформы).", + "1_2_read_carefully_dfde346e": "1.2 Читайте внимательно:", + "this_system_is_a_general_purpose_full_f1d395fe": "Эта система представляет собой универсальную полнофункциональную основу для настройки, обеспечения регистрации, сеансов, интеграции OIDC, аутентификации по ключу API и администрирования. Пожалуйста, прочтите все условия перед использованием.", + "1_3_agreement_contents_f05cebc0": "1.3 Содержание Соглашения:", + "the_agreement_includes_these_terms_and_any_43e244b6": "Соглашение включает в себя настоящие условия, а также любые правила, заявления или пояснения, которые мы опубликовали или можем опубликовать. Эти правила являются неотъемлемой частью Соглашения и имеют равную юридическую силу.", + "2_service_definition_and_nature_61db87f8": "2. Определение и характер услуги", + "your_responsibility_527cbdbf": "Ваша ответственность:", + "the_entity_deploying_and_operating_a_customized_047c6a8f": "Организация, развертывающая и эксплуатирующая настроенную версию этой системы, принимает на себя всю ответственность за соблюдение требований в отношении своей деловой деятельности в других производственных средах.", + "3_registration_and_account_use_cfab1555": "3. Регистрация и использование аккаунта", + "3_1_accounts_2631c114": "3.1 Счета:", + "users_may_register_through_the_frontend_or_e527ef52": "Пользователи могут зарегистрироваться через интерфейс или связать логин через стороннего поставщика OIDC, включенного администратором.", + "3_2_password_and_token_security_80dd80d9": "3.2 Безопасность пароля и токена:", + "password_security_7a29e44e": "Безопасность пароля:", + "keep_your_account_password_secure_eb9adf10": "Держите пароль своей учетной записи в безопасности.", + "api_token_security_34af066f": "Безопасность токена API:", + "a_personal_access_token_api_key_represents_6215b3b0": "Токен личного доступа (ключ API) представляет полные разрешения API вашей учетной записи.", + "you_are_responsible_for_data_loss_or_0c9acc88": "Вы несете ответственность за потерю данных или повреждение системы в результате утечки токена, вызванной ненадлежащим хранением.", + "4_prohibited_user_conduct_bd5628b6": "4. Запрещенное поведение пользователя", + "when_using_this_system_you_must_comply_1e54356c": "При использовании этой системы вы должны соблюдать применимые законы, в том числе Закон о кибербезопасности Китайской Народной Республики и Меры по обеспечению безопасности компьютерных информационных сетей, подключенных к Интернету.", + "the_following_activities_are_strictly_prohibited_5b57de7f": "Строго запрещены следующие виды деятельности:", + "endangering_national_security_4a160c4e": "Угроза национальной безопасности:", + "opposing_constitutional_principles_endangering_national_security_disclosing_bdfb8814": "Противодействие конституционным принципам, создание угрозы национальной безопасности, разглашение государственной тайны, подрыв государственной власти или подрыв национального единства;", + "illegal_information_services_c97748bb": "Незаконные информационные услуги:", + "hacking_tools_ddos_attack_services_server_brute_686746c4": "Хакерские инструменты, услуги DDoS-атак, услуги перебора серверов или другие нелегальные информационные сервисные платформы;", + "pornography_gambling_and_drugs_fdb44a54": "Порнография, азартные игры и наркотики:", + "creating_copying_publishing_or_distributing_obscene_pornographic_ec8c0fdb": "Создание, копирование, публикация или распространение непристойного, порнографического, связанного с азартными играми, жестокого, убийственного, террористического или провоцирующего преступления контента;", + "intellectual_property_infringement_cc10b950": "Нарушение интеллектуальной собственности:", + "selling_pirated_software_or_media_illegal_game_3f0f4159": "Продажа пиратского программного обеспечения или носителей, незаконных игровых читов, неавторизованных игровых серверов, украденных учетных записей или утечки личных баз данных;", + "other_unlawful_information_4085c17c": "Другая незаконная информация:", + "spreading_rumors_promoting_cults_or_superstition_insulting_a062981a": "Распространение слухов, пропаганда культов или суеверий, оскорбление или клевета на других людей, а также нарушение их законных прав.", + "enforcement_9c7cfd68": "Правоприменение:", + "if_these_rules_are_violated_the_platform_86df3b18": "В случае нарушения этих правил оператор платформы может без предварительного уведомления ", + "permanently_suspend_your_account_block_all_access_b9f04a42": "навсегда заблокировать вашу учетную запись, заблокировать все запросы токенов доступа и передать соответствующую информацию органам общественной безопасности или кибербезопасности, как того требует закон.", + "5_disclaimer_and_force_majeure_be649f43": "5. Отказ от ответственности и форс-мажорные обстоятельства", + "5_1_general_disclaimer_30d01f0d": "5.1 Общий отказ от ответственности:", + "this_development_platform_is_provided_as_is_23bc2b55": "Эта платформа разработки предоставляется в том виде, в каком она есть и доступна. Мы не гарантируем, что он соответствует вашим конкретным потребностям разработки, и не предоставляем дополнительных гарантий своевременности, безопасности или точности.", + "5_2_service_interruptions_16558fa8": "5.2 Перебои в обслуживании:", + "the_platform_is_not_liable_for_interruptions_405b1c95": "Платформа не несет ответственности за сбои, потерю данных или повреждение учетной записи, вызванные следующими форс-мажорными обстоятельствами:", + "natural_disasters_including_typhoons_earthquakes_tsunamis_and_1bbbfb21": "Стихийные бедствия, включая тайфуны, землетрясения, цунами и наводнения;", + "government_actions_cybersecurity_law_changes_or_administrative_2b7322f5": "Действия правительства, изменения в законодательстве о кибербезопасности или административные приказы;", + "telecommunication_faults_or_damage_to_data_center_259a542f": "Сбои в телекоммуникациях или повреждение центров обработки данных или подводных кабелей;", + "data_corruption_or_downtime_caused_by_hacking_68f2e04f": "Повреждение данных или простои, вызванные взломом или программами-вымогателями.", + "6_governing_law_and_disputes_b9663c86": "6. Применимое право и споры", + "6_1_governing_law_ff2afc5e": "6.1 Применимое право:", + "the_formation_performance_interpretation_and_resolution_of_c1510775": "Формирование, ведение, толкование и разрешение споров по настоящему Соглашению регулируются ", + "the_laws_of_the_people_s_republic_5d5c80d6": "законы Китайской Народной Республики", + "excluding_the_conflict_of_laws_rules_of_b235c4e3": " (за исключением коллизионных норм Гонконга, Макао и Тайваня).", + "6_2_dispute_resolution_44e871de": "6.2 Разрешение споров:", + "disputes_should_first_be_resolved_through_friendly_1ccf4610": "Споры следует сначала разрешать путем дружеских переговоров. Если переговоры не увенчались успехом, они должны быть переданы в компетентный народный суд по месту нахождения развертывающего или оператора системы.", + "failed_to_load_value0_7f5a06a3": "Не удалось загрузить {value0}", + "load_failed_d1d04482": "Загрузка не удалась", + "no_data_497c8569": "Нет данных", + "loading_d04fcbda": "Загрузка", + "saved_e05ddf99": "Сохранено", + "save_failed_6309a3bb": "Сохранить не удалось", + "unknown_error_13a46616": "Неизвестная ошибка", + "deleted_5223f91b": "Удалено", + "delete_failed_c228558c": "Удаление не удалось", + "configuration_list_53052147": "Список конфигурации", + "confirm_delete_2d61cf17": "Подтвердить удаление?", + "this_cannot_be_undone_this_will_permanently_8c896756": "Это невозможно отменить. Это приведет к безвозвратному удалению конфигурации.", + "cancel_2cd0f3be": "Отмена", + "confirm_delete_a3ea3c17": "Подтвердить удаление", + "configuration_info_7f69a860": "Информация о конфигурации", + "select_a_configuration_to_view_details_ec4949ab": "Выберите конфигурацию, чтобы просмотреть подробности", + "updating_e1d9dd03": " Обновление", + "update_3055a035": "Сохранить", + "actions_ed31fbb4": "Действия", + "enter_password_728a7b60": "Введите пароль", + "for_your_account_security_please_enter_the_28fc0bbd": "Для безопасности вашей учетной записи введите 6-значный пароль подтверждения.", + "verifying_6c128e0a": "Проверка…", + "confirm_36f33ada": "Подтвердить", + "profile_b94d13a4": "Профиль", + "manage_your_account_information_and_preferences_cced83b4": "Управляйте информацией и настройками своей учетной записи", + "user_guide_c83b6b0a": "Руководство пользователя", + "api_integration_and_everyday_operation_guides_281269a1": "API руководства по интеграции и повседневной работе", + "system_settings_68ea5dd4": "Системные настройки", + "manage_registration_login_and_authentication_provider_security_f378ed37": "Управляйте безопасностью поставщика регистрации, входа и аутентификации.", + "global_system_configuration_1eb8931e": "Глобальная конфигурация системы", + "manage_core_platform_runtime_settings_341e4182": "Управление настройками среды выполнения базовой платформы", + "user_permissions_8e8b1d33": "Разрешения пользователя", + "view_and_manage_registered_users_and_their_db533b7c": "Просмотр и управление зарегистрированными пользователями и статусом их учетной записи", + "background_task_scheduling_408172d1": "Планирование фоновых задач", + "dispatch_and_monitor_background_and_scheduled_tasks_83967fc9": "Отправка и мониторинг фоновых и запланированных задач", + "system_logs_366ada1d": "Системные журналы", + "view_background_task_logs_and_system_status_24fd6a7d": "Просмотр журналов фоновых задач и состояния системы", + "a_modern_extensible_administration_platform_with_ready_0d95c0c1": "Современная расширяемая платформа администрирования с готовой инфраструктурой для создания корпоративных приложений.", + "get_started_715ee9dc": "Начать", + "hide_tip_738f4b27": "Скрыть подсказку", + "quick_navigation_05deefcb": "Быстрая навигация", + "open_fee3b5a5": "Открыть ", + "open_df42ebc3": "Открыть ", + "administration_console_ceb0c289": "Консоль администрирования", + "live_origin_error_page_preview_a2a12823": "Предварительный просмотр страницы ошибки в реальном времени", + "html_editor_19f0d7b7": "Редактор HTML", + "bytes_f5d87a8b": " байты", + "leave_empty_for_the_built_in_default_29ffce9f": " · Оставьте пустым для встроенного значения по умолчанию.", + "leave_empty_to_use_the_built_in_e57252bd": "Оставьте пустым, чтобы использовать встроенный шаблон по умолчанию…", + "drag_to_resize_b5154511": "Перетащите, чтобы изменить размер", + "live_preview_a76fdaad": "Предпросмотр", + "preview_13d61fea": "Предпросмотр", + "for_example_github_1061e2e4": "Например: гитхаб", + "for_example_github_login_1e0c113c": "Например: GitHub логин", + "https_or_leave_empty_a0d86d9b": "https://... или оставьте пустым", + "missing_configuration_value0_31b43fa6": "Отсутствует конфигурация: {value0}", + "missing_configuration_file_access_whitelist_6dd80936": "Отсутствует конфигурация: file_access_whitelist", + "directory_visibility_configuration_json_string_format_url_8e3c0671": "Конфигурация видимости каталога (строка JSON, формат: {url: enabled})", + "missing_configuration_login_session_ttl_hours_d6f433c2": "Отсутствует конфигурация: login_session_ttl_hours", + "server_address_d3716cc5": "Адрес сервера", + "platform_display_name_4b656edf": "Отображаемое имя платформы", + "for_example_openflare_28e07341": "Например: OpenFlare", + "for_example_https_example_com_9cf31711": "Например: https://example.com", + "allow_search_engines_to_crawl_and_index_59eff8b5": "Разрешить поисковым системам сканировать и индексировать", + "for_example_smtp_example_com_834569b4": "Например: smtp.example.com.", + "for_example_587_or_465_1bd015a5": "Например: 587 или 465.", + "for_example_sender_example_com_1795f7c6": "Например: sender@example.com", + "for_example_receiver_example_com_ade0ff8b": "Например: receiver@example.com", + "for_example_login_email_c7212e1f": "Например: login_email", + "for_example_login_verification_email_9d28f166": "Например: электронное письмо для подтверждения входа.", + "for_example_openflare_login_verification_code_0925e8d4": "Например: OpenFlare код подтверждения входа.", + "for_example_your_code_is_code_valid_44cf273f": "например Электронная почта для подтверждения. Доступная переменная: {{.Code}}", + "h3_message_title_h3_p_message_content_20c91d04": "

    Название сообщения

    Содержание сообщения; доступная переменная: {{.Code}}

    ", + "register_a_user_curl_x_post_https_4ae682b0": "# Register a user\ncurl -X POST https://api.example.com/api/v1/auth/register \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"username\": \"developer\",\n \"email\": \"dev@example.com\",\n \"password\": \"secure_password\"\n }'", + "quick_start_42fbb067": "# Быстрый старт", + "developer_friendly_40c2a4ce": "Дружественный к разработчикам, ", + "quick_to_integrate_18cbee8a": "быстрая интеграция", + "standard_restful_apis_complete_typescript_types_detailed_6369a0e2": "Стандартные API-интерфейсы RESTful, полные типы TypeScript, подробная документация и Swagger UI. Интегрируйтесь с помощью всего лишь нескольких строк кода на предпочитаемом вами языке.", + "restful_api_with_clear_understandable_semantics_1fa9d6f5": "RESTful API с четкой и понятной семантикой", + "openapi_swagger_documentation_ccbcbc84": "OpenAPI / Swagger документация", + "developer_friendly_typescript_type_definitions_910a7909": "Удобные для разработчиков определения типа TypeScript", + "code_samples_and_integration_guides_9e998a5a": "Примеры кода и руководства по интеграции", + "an_open_source_platform_for_managing_edge_317d8f34": "Платформа с открытым исходным кодом для управления пограничными узлами и обратными прокси. Создан на Go и React для управления узлами OpenResty, правилами веб-сайта и конфигурацией WAF.", + "product_aea82737": "Продукт", + "dashboard_0853bf3c": "Обзор", + "personal_c2952a5f": "Персональный", + "development_9ec42862": "Разработка", + "source_code_606a4e58": "Исходный код", + "community_5bcd0ddc": "Сообщество", + "discussions_3cef912c": "Обсуждения", + "privacy_policy_e464b41f": "Политика конфиденциальности", + "terms_of_service_9303687a": "Условия обслуживания", + "built_for_customization_ff3cf147": "Создан для индивидуальной настройки", + "a_modern_administration_platform_116688ff": "Современная платформа администрирования", + "ready_to_use_infrastructure_that_is_easy_77776634": "Готовая к использованию инфраструктура, которую легко расширить для корпоративных приложений.", + "get_started_d1f9c450": "Начать", + "learn_more_ca66c2da": "Узнать больше", + "ready_to_use_c58cfea2": "Готов к использованию", + "highly_extensible_9dfa6c10": "Высокая расширяемость", + "production_infrastructure_08c3a5e6": "Производственная инфраструктура", + "good_fortune_b3541887": "Удача", + "retry_b8784c8d": "Повторить", + "an_unknown_error_occurred_please_try_again_79a9f98a": "Произошла неизвестная ошибка. Пожалуйста, повторите попытку позже.", + "view_error_details_9e7e4e37": "Просмотр сведений об ошибке", + "unknown_error_e83a8ca8": "Неизвестная ошибка", + "fetching_data_9ac5e428": "Получение данных...", + "type_and_press_enter_to_add_e30b7b5f": "Введите и нажмите Enter, чтобы добавить", + "remove_value0_30c2158a": "Удалить {value0}", + "fnv_1a_32_bit_function_fnv1a_str_a1690b96": "\n// FNV-1a 32-bit\nfunction fnv1a(str) {\n let h = 2166136261 >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619) >>> 0;\n }\n return h;\n}\nfunction fnv1aResume(state, str) {\n let h = state >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619) >>> 0;\n }\n return h;\n}\n\n// xorshift32 PRNG → hex string\nfunction prngFromHash(seed, len) {\n let s = seed >>> 0, r = '';\n while (r.length < len) {\n s ^= (s << 13) >>> 0;\n s ^= (s >>> 17) >>> 0;\n s ^= (s << 5) >>> 0;\n s = s >>> 0;\n r += s.toString(16).padStart(8, '0');\n }\n return r.slice(0, len);\n}\n\n// SHA-256 via SubtleCrypto (available in workers)\nasync function sha256Hex(input) {\n const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));\n return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Solve one puzzle\nasync function solvePuzzle(token, index, size, difficulty) {\n const tFnv = fnv1a(token);\n const saltSeed = fnv1aResume(tFnv, String(index + 1));\n const targetSeed = fnv1aResume(saltSeed, 'd');\n const salt = prngFromHash(saltSeed, size);\n const target = prngFromHash(targetSeed, difficulty);\n\n for (let nonce = 0; nonce < 10_000_000; nonce++) {\n if ((await sha256Hex(salt + nonce)).startsWith(target)) return nonce;\n }\n throw new Error('Could not solve puzzle ' + (index + 1) + ' within the allowed search range');\n}\n\n// Entry point — receive task from main thread\nself.onmessage = async ({ data }) => {\n const { token, count, size, difficulty } = data;\n try {\n const t0 = performance.now();\n const solutions = [];\n for (let i = 0; i < count; i++) {\n solutions.push(await solvePuzzle(token, i, size, difficulty));\n }\n const elapsed = ((performance.now() - t0) / 1000).toFixed(3);\n self.postMessage({ type: 'done', solutions, elapsed });\n } catch (err) {\n self.postMessage({ type: 'error', message: err.message });\n }\n};\n", + "worker_execution_failed_1877e35b": "Ошибка выполнения воркера", + "failed_to_fetch_the_verification_challenge_95553133": "Не удалось получить запрос на проверку.", + "cap_starting_verification_pow_solver_657f3af5": "[Шапка] Запускаем верификацию PoW решателя", + "challenge_count_value0_ea1ffc80": " Количество испытаний: {value0}", + "difficulty_value0_f104cb0d": " Сложность: {value0}", + "salt_size_value0_4f2bae5d": " Размер соли: {value0}", + "cap_verification_pow_solver_completed_e1f79fd5": "[Cap] Проверка решателя PoW завершена", + "worker_duration_value0_s_8de32777": " Продолжительность рабочего: {value0}s", + "total_duration_including_communication_value0_s_004a6894": " Общая продолжительность (включая общение): {value0}s", + "verification_failed_d3925ab2": "Проверка не удалась", + "doctype_html_html_lang_en_head_meta_3e4f5ecb": "\n\n\n\n \n \n Офлайн-страница | OpenFlare\n \n\n\n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    РЕЗЕРВНАЯ СТРАНИЦА
    \n

    Сайт недоступен

    \n

    \n Сервер-источник временно недоступен. Этот запрос обработан Service Worker в автономном режиме. Свяжитесь с администратором по контактам ниже.\n

    \n
    \n
    \n Почта администратора\n admin@example.com\n
    \n
    \n Состояние сервиса\n status.example.com\n
    \n
    \n
    \n
    \n
    \n \n \n \n OpenFlare Service Worker\n
    \n БЕЗ СЕТИ\n
    \n
    \n\n\n", + "minimal_white_5636e8a9": "Минимализм", + "line_topology_22ad450c": "Схема соединений", + "bauhaus_5cc69781": "Bauhaus", + "doctype_html_html_lang_en_head_meta_42a4f2da": "\n\n\n\n \n \n Офлайн-страница | OpenFlare\n \n\n\n
    \n
    \n
    \n \n SERVICE WORKER НЕТ СЕТИ CACHE\n
    \n

    Сеть прервана или источник недоступен

    \n

    \n Резервная защита Service Worker активна. Свяжитесь с администратором по следующим каналам:\n

    \n
    \n
    \n Поддержка по электронной почте\n admin@example.com\n
    \n
    \n Страница статуса\n status.example.com\n
    \n
    \n
    \n
    \n
    \n \n \n \n OpenFlare Край\n
    \n FALLBACK ACTIVE\n
    \n
    \n\n\n", + "doctype_html_html_lang_en_head_meta_505c1308": "\n\n\n\n \n \n Офлайн-страница | OpenFlare\n \n\n\n
    \n
    НЕТ СЕТИ CONTACT
    \n

    Сайт недоступен: свяжитесь с администратором

    \n

    \n Источник в настоящее время недоступен. Автономная защита Service Worker активна. Свяжитесь с администратором по контактам ниже.\n

    \n
    \n
    \n \n Контактный адрес электронной почты: admin@example.com\n
    \n
    \n \n Состояние сервиса: status.example.com\n
    \n
    \n
    \n \n \n \n OpenFlare Защита\n
    \n
    \n\n\n", + "status_code_tag_must_not_be_empty_21235ebf": "Тег кода статуса не должен быть пустым.", + "invalid_status_code_range_value0_8983e96e": "Неверный диапазон кода состояния: {value0}", + "invalid_status_code_value0_0f206e48": "Неверный код статуса: {value0}", + "status_code_range_endpoints_are_reversed_value0_d92c2784": "Конечные точки диапазона кода состояния поменяны местами: {value0}", + "status_code_must_be_between_value0_value1_c5b04822": "Код состояния должен находиться в диапазоне {value0}–{value1}: {value2}", + "invalid_status_code_tag_d16e85d7": "Неверный тег кода состояния", + "add_at_least_one_status_code_tag_b81b2696": "Добавьте хотя бы один тег кода статуса.", + "the_expanded_status_code_list_is_empty_47245f34": "Расширенный список кодов состояния пуст.", + "request_canceled_5892f9b1": "Запрос отменен", + "insufficient_permissions_complete_verification_and_try_again_73719073": "Недостаточно разрешений; завершите проверку и повторите попытку", + "the_requested_resource_was_not_found_aecc0681": "Запрошенный ресурс не найден", + "request_parameter_validation_failed_60ef7c51": "Проверка параметров запроса не удалась", + "too_many_requests_retry_in_value0_seconds_ab5e78f1": "Слишком много запросов. Повторите через {value0, plural, one {# секунду} few {# секунды} many {# секунд} other {# секунды}}.", + "request_rate_limit_ad4f0f8b": "Ограничение скорости запроса", + "internal_server_error_try_again_later_4f0dd7e9": "Внутренняя ошибка сервера; попробуйте еще раз позже", + "cannot_connect_to_the_server_check_that_5551f4fc": "Невозможно подключиться к серверу; проверьте, что бэкэнд работает", + "network_request_failed_8407ceab": "Сетевой запрос не выполнен", + "request_canceled_manually_7ba622e6": "Запрос отменен вручную", + "all_requests_canceled_72f8fa9a": "Все запросы отменены", + "network_connection_failed_check_your_network_cbf47b07": "Сетевое соединение не удалось; проверьте свою сеть", + "request_timed_out_try_again_later_6c3465f6": "Время запроса истекло; попробуйте еще раз позже", + "unauthorized_sign_in_first_466879f5": "Несанкционированный; сначала войдите в систему", + "insufficient_permissions_5e3c8a26": "Недостаточно разрешений", + "value0_must_be_a_valid_number_1d79f887": "{value0}: укажите корректное число", + "value0_must_be_true_or_false_23cce68e": "{value0}: допустимы только true или false", + "value0_must_not_be_empty_8bf3a929": "{value0} не должно быть пустым", + "request_canceled_282c4cfb": "Запрос отменен", + "base64_encoding_is_not_supported_in_this_7642d848": "Кодировка Base64 не поддерживается в этой среде.", + "rate_limit_retry_later": "Слишком много запросов; попробуйте еще раз позже", + "list_separator": ", ", + "sentence_end": ".", + "task_notification_template_example": "{\"title\":\"Задача завершена\",\"content\":\"{{task_name}}: {{task_status}}\",\"level\":\"INFO\"}", + "ui_totalRequests": "Всего запросов", + "ui_totalVisits": "Всего посещений", + "ui_bandwidthServed": "Передано данных", + "ui_requestsOverTime": "Динамика запросов", + "ui_requestsByDevice": "Запросы по типам устройств", + "ui_statusCodes": "Коды ответа", + "ui_topPaths": "Популярные пути", + "ui_topHosts": "Популярные хосты", + "ui_topIps": "Популярные IP-адреса", + "ui_topBrowsers": "Популярные браузеры", + "ui_topOperatingSystems": "Операционные системы", + "ui_topUserAgents": "Популярные User-Agent", + "ui_deviceTypes": "Типы устройств", + "ui_fileHash": "Хэш (SHA-256)", + "ui_pathStyle": "Адресация через путь (Path Style)", + "ui_requestHeaders": "Заголовки запроса", + "ui_barkApp": "Приложение Bark", + "ui_administrator": "Администратор", + "ui_nodeLabel": "Узел:", + "ui_payload": "Параметры задачи", + "ui_selectAll": "Выбрать всё", + "ui_production": "Рабочая версия", + "ui_allDeployments": "Все развёртывания", + "ui_remoteUrl": "URL архива", + "ui_githubRelease": "Релиз GitHub", + "ui_releaseTag": "Тег релиза", + "ui_originHostHeader": "Заголовок Host сервера-источника", + "ui_globalRuleGroup": "Глобальная группа правил", + "ui_zone": "Зона DNS", + "ui_pageNotFound": "Страница не найдена", + "ui_jsonBody": "Тело JSON", + "ui_openTelemetryTracing": "Трассировка OpenTelemetry:", + "ui_clientId": "ID клиента", + "ui_clientSecret": "Секрет клиента", + "ui_scopes": "Области доступа", + "ui_copyright": "© 2026 Modern Platform. Все права защищены.", + "ui_technologyStack": "Технологии", + "ui_readyFor": "Готово к", + "ui_development": "Разработке", + "ui_features": "Возможности", + "ui_modern": "Современный", + "ui_versionLabel": "Версия", + "ui_builtAt": "Дата сборки", + "ui_breadcrumb": "Навигационная цепочка", + "ui_more": "Ещё", + "ui_previousSlide": "Предыдущий слайд", + "ui_nextSlide": "Следующий слайд", + "ui_closeLabel": "Закрыть", + "ui_imageCrop": "Обрезка изображения", + "ui_pagination": "Страницы", + "ui_previousPage": "Предыдущая страница", + "ui_previousLabel": "Назад", + "ui_nextPage": "Следующая страница", + "ui_nextLabel": "Далее", + "ui_morePages": "Другие страницы", + "ui_sidebarLabel": "Боковая панель", + "ui_mobileSidebarDescription": "Меню навигации на мобильных устройствах.", + "ui_toggleSidebar": "Переключить боковую панель", + "ui_loadingLabel": "Загрузка", + "origin_error_page_minimalist": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n

    {{status}}

    \n

    \n Сервер-источник недоступен. Попробуйте позже. Если проблема повторяется, обратитесь к администратору сайта.\n

    \n

    {{host}}

    \n
    \n \n \n \n OpenFlare\n
    \n
    \n\n\n", + "origin_error_page_line_style": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n
    \n
    {{status}}
    \n
    Сервер-источник недоступен
    \n
    \n\n
    \n \n \n \n \n \n \n \n \n \n\n \n \n\n \n CLI\n КЛИЕНТ\n ЗАПРОС ОТПРАВЛЕН\n\n \n \n OPENFLARE\n ПРОКСИ\n\n \n \n \n ИСТОЧНИК\n НЕДОСТУПЕН\n \n
    \n\n
    \n \n \n \n OpenFlare\n
    \n
    \n\n \n\n\n", + "origin_error_page_baohaus": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n
    \n
    \n
    {{status}}
    \n\n
    \n \n \n \n OpenFlare\n
    \n
    \n\n
    \n

    Сервер-источник недоступен

    \n

    \n Сервер получил некорректный ответ от сервера-источника.\n

    \n Форма следует функции: Соединение прервано. Проверьте состояние сервера-источника.\n

    \n
    \n
    КОД: {{status}}
    \n
    ХОСТ: {{host}}
    \n
    ПРОКСИ: OPENFLARE_EDGE
    \n
    --:--:-- UTC
    \n
    \n
    \n\n
    \n
    \n
    \n
    \n
    \n
    \n\n \n\n\n", + "ui_accountId": "ID учётной записи", + "ui_endpointLabel": "Адрес сервиса", + "ui_regionLabel": "Регион", + "ui_bucketLabel": "Бакет", + "ui_accessKeyId": "ID ключа доступа", + "ui_secretAccessKey": "Секретный ключ доступа", + "ui_isoCode": "Код ISO", + "ui_serverUrl": "URL сервера", + "ui_commandPalette": "Палитра команд", + "ui_commandSearch": "Найти команду…", + "ui_botLabel": "Бот", + "ui_unknownLabel": "Неизвестно", + "ui_otherLabel": "Прочее", + "ui_toastRegion": "Уведомления", + "ui_closeToast": "Закрыть уведомление", + "node_type_edge": "Пограничный узел", + "node_type_relay": "Ретранслятор", + "node_type_tunnel": "Туннель", + "system_metric_day": "{count} дн.", + "system_metric_hour": "{count} ч", + "system_metric_minute": "{count} мин", + "system_metric_second": "{count} с", + "system_metric_unknown": "Неизвестно", + "system_metric_none": "Нет" + } +} diff --git a/frontend/messages/fragments/runtime.zh-CN.json b/frontend/messages/fragments/runtime.zh-CN.json new file mode 100644 index 00000000..f5c623c8 --- /dev/null +++ b/frontend/messages/fragments/runtime.zh-CN.json @@ -0,0 +1,575 @@ +{ + "runtime": { + "api_documentation_cb3d2229": "API 接口文档", + "openflare_provides_a_simple_powerful_api_for_ba400468": "OpenFlare 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 RESTful 接口,您可以轻松地与后端服务进行集成与二次开发。", + "platform_user_guide_d0ead991": "平台使用指南", + "welcome_to_the_openflare_management_platform_this_e8ecbe4d": "欢迎使用 OpenFlare 管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。", + "privacy_policy_b6da1ca6": "隐私政策 (Privacy)", + "we_value_your_privacy_this_policy_explains_2c9e3c0b": "我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,", + "and_the_rights_you_have_over_it_4560abf2": "以及您享有的相关权利。", + "terms_of_service_4e80a140": "服务协议 (Terms)", + "please_read_the_following_terms_carefully_to_c46a6a61": "为了保障您的合法权益,请您仔细阅读以下条款。", + "use_1b3d699e": ":使用", + "or_07d4e3c0": "(或", + "place_the_lucide_icon_directly_inside_the_104f794c": ":直接将 Lucide 图标组件嵌套于标题容器中,应用", + "use_only_5e12eecb": ":文本使用且仅使用", + "for_pages_with_multiple_tabs_keep_each_5f721959": ":凡是带有多个 Tab 页切换的页面,**禁止**将所有 Tab 的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的 Content 必须单独拆分为独立组件文件(如", + "beyond_tabs_when_a_page_exceeds_600_d152cfdc": ":不仅是 Tabs 切换,当一个页面代码行数超过 600", + "message_18ab4763": ") 的", + "and_de1d5dd0": "与", + "inputs_fabee312": "输入框 (", + "and_selectors_e4875cdf": ") 与下拉选择器 (", + "should_share_the_compact_height_f1fe2218": ") 的高度应统一限制为小号高度", + "with_font_size_e7c29991": ",字体大小使用", + "and_use_ca398a12": ",并使用", + "to_remove_overlapping_native_shadows_dd65b475": "去除原生的重叠阴影。", + "align_checkboxes_toward_the_top_left_using_092dcef0": "Checkbox 应在左侧垂直偏上对齐,使用容器", + "add_bf15da3a": "排列。Checkbox 组件本身添加", + "to_the_checkbox_so_multiline_descriptions_stay_1bb85c4b": ",从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。", + "use_the_default_primary_or_secondary_db7513e5": "普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要", + "button_for_forms_and_create_save_actions_a657dba8": "按钮,小号高度统一采用", + "outline_buttons_must_include_the_66ace005": "。线框按钮必须带有", + "class_43eff5ff": "类。", + "1_align_label_and_input_32d4558d": "// 1. Label 与 Input 对齐", + "div_classname_space_y_1_5_label_32230ad7": "
    \n \n \n
    ", + "2_align_checkbox_to_the_top_bdf68ad8": "// 2. Checkbox 顶对齐", + "div_classname_flex_items_start_gap_2_ccf0d526": "
    \n \n
    \n \n

    多行说明内容

    \n
    \n
    ", + "card_classname_border_dashed_shadow_none_cardheader_35727acf": "\n \n 指标标题\n \n \n \n
    42.8 %
    \n \n

    状态说明小字

    \n
    \n
    ", + "div_classname_border_border_dashed_shadow_none_b430b520": "
    \n \n \n \n 编号\n 状态\n \n \n \n \n ID-1001\n \n \n \n 成功\n \n \n \n \n
    \n
    ", + "disabled_f95ea7f4": "未启用", + "title_administrator_login_alert_content_administrator_user_6f32cf7c": "{\"title\": \"管理员登录提醒\", \"content\": \"管理员 {{user.username}} ...\", \"level\": \"INFO\"}", + "lark_webhook_c2e0b312": "飞书 Webhook", + "title_content_view_details_url_7af4b7d5": "### $title\n$content\n\n[查看详情]($url)", + "dingtalk_group_bot_5d8efec9": "钉钉群机器人", + "wecom_group_bot_138ea7a8": "企业微信群机器人", + "under_development_78c33fbf": "开发中", + "this_feature_is_being_migrated_and_developed_61ebaf78": "该功能正在迁移开发中,敬请期待。", + "port_for_the_built_in_frps_web_cfab540f": "FRPS 内置 Web 管理界面端口", + "request_failed_please_try_again_later_8977b79a": "请求失败,请稍后重试。", + "operation_failed_0c3b4cf7": "操作失败", + "automatic_configuration_must_be_a_json_object_90ba17a4": "自动配置必须是 JSON 对象。", + "high_frequency_404_scan_from_one_ip_f7d27ffb": "单 IP 404 高频扫描", + "abnormal_direct_access_from_one_ip_732dbeee": "单 IP 直连访问异常", + "permanent_3e71ccc8": "永久", + "expired_2fe0e333": "已过期", + "in_value0_minutes_fd2a5b53": "{value0} 分钟后", + "in_value0_hours_5ca37144": "{value0} 小时后", + "node_2410d860": "节点", + "edge_099db313": "边", + "edge_s_id_74f6affc": "边(?:\\s+ID)?", + "value0_s_value1_s_40960c98": "{value0}\\s+{value1}(?=$|[\\s,。,::的])", + "rule_graph_schema_version_must_be_1_ea9a1161": "规则图 schema_version 必须为 1", + "rule_graph_exceeds_the_size_limit_337651b9": "规则图超过大小限制", + "node_id_must_not_be_empty_51a5de58": "节点 ID 不能为空", + "duplicate_node_id_value0_621230af": "节点 ID {value0} 重复", + "edge_id_must_not_be_empty_bc8f69cb": "连线 ID 不能为空", + "duplicate_edge_id_value0_0a9d1c7c": "连线 ID {value0} 重复", + "the_start_node_must_not_have_incoming_50a07b17": "开始节点不能有入边", + "the_rule_graph_must_contain_exactly_one_7dfbc027": "规则图必须恰好有一个开始节点", + "the_rule_graph_must_contain_exactly_one_0036eb82": "规则图必须恰好有一个通过节点", + "edge_value0_has_an_invalid_endpoint_or_9d84eb7e": "连线 {value0} 的端点或出口无效", + "node_value0_output_value1_is_not_connected_c3038e81": "节点 {value0} 的 {value1} 出口未连接", + "node_value0_output_value1_can_only_be_903e54ce": "节点 {value0} 的 {value1} 出口只能连接一次", + "node_value0_is_unreachable_from_the_start_086531dc": "节点 {value0} 无法从开始节点到达", + "node_value0_cannot_reach_a_terminal_node_8de2ff6e": "节点 {value0} 无法抵达终止节点", + "the_rule_graph_must_not_contain_cycles_fd6fe797": "规则图不能包含循环", + "node_value0_contains_an_invalid_ip_address_1873ab94": "节点 {value0} 包含无效 IP", + "node_value0_contains_an_invalid_cidr_00c880d9": "节点 {value0} 包含无效 CIDR", + "node_value0_contains_an_invalid_ip_group_589219a5": "节点 {value0} 包含无效 IP 组", + "node_value0_contains_an_invalid_geography_code_1a9f469b": "节点 {value0} 包含无效地域代码", + "node_value0_has_pow_settings_outside_the_9fd2a4cb": "节点 {value0} 的 PoW 配置超出范围", + "node_value0_has_invalid_block_response_settings_ae4c35ca": "节点 {value0} 的阻止响应配置无效", + "node_value0_match_mode_must_be_and_242c1ae7": "节点 {value0} 的匹配模式必须为 and 或 or", + "node_value0_contains_an_invalid_browser_tag_4d0e6740": "节点 {value0} 包含无效浏览器标签", + "node_value0_contains_an_invalid_operating_system_45bea1fb": "节点 {value0} 包含无效操作系统标签", + "node_value0_allows_at_most_32_custom_497938c6": "节点 {value0} 的自定义 UA 正则不能超过 32 条", + "node_value0_custom_ua_pattern_must_not_a72718be": "节点 {value0} 的自定义 UA 正则不能为空", + "node_value0_custom_ua_pattern_is_too_c2c83c0e": "节点 {value0} 的自定义 UA 正则过长", + "node_value0_has_an_invalid_custom_ua_c47ffd0d": "节点 {value0} 的自定义 UA 正则无效", + "node_value0_requires_at_least_one_pattern_6114bb93": "节点 {value0} 开启屏蔽自定义 UA 时至少需要一条正则", + "start_d2bb025a": "开始", + "ip_match_43f4b6d4": "IP 匹配", + "geography_match_9acb08c4": "地域匹配", + "ua_check_b56dac23": "UA 检查", + "security_checks_e09822e6": "安全防护", + "pow_challenge_3c043088": "PoW 挑战", + "allow_1e9f2561": "通过", + "block_2a366d09": "阻止", + "wechat_81b430b6": "微信", + "wildcard_c6b0739e": "通配符", + "exact_match_391fe725": "精确匹配", + "value0_expires_value1_7d8600f8": "{value0}(到期:{value1})", + "sorry_the_page_you_requested_seems_to_ba66cd83": "抱歉,您访问的页面似已迷失在数字星云中。", + "check_the_path_or_return_to_safety_b1f31888": "请检查路径或返回安全地带。", + "previous_c9b9ae7a": "上一页", + "home_203c08e0": "首页", + "1_api_conventions_and_authentication_e6d4df61": "1. 接口规范与鉴权说明", + "standard_api_request_conventions_and_developer_access_39eb956e": "平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明", + "1_1_standard_response_format_5cddae2f": "1.1 统一响应格式", + "all_api_endpoints_use_a_standard_json_87c92fb0": "系统所有 API 接口均遵循标准 JSON 响应结构:", + "field_49ecd0e3": "字段", + "type_ba40014f": "类型", + "description_4262c45d": "说明", + "error_message_empty_on_success_contains_error_d89d457f": "错误信息。请求成功时为空字符串 `\"\"`,失败时包含错误详情描述。", + "the_response_payload_null_when_the_request_97b8b29c": "接口返回的具体数据内容。请求失败或无数据返回时为 `null`。", + "successful_response_example_8917e853": "成功响应示例:", + "failed_response_example_eae899e1": "失败响应示例:", + "error_msg_incorrect_password_data_null_b2eccc86": "{\n \"error_msg\": \"用户密码错误\",\n \"data\": null\n}", + "1_2_authentication_methods_3c5db38a": "1.2 鉴权方式", + "protected_endpoints_require_credentials_except_public_endpoints_1fa0a24c": "除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:", + "session_credentials_de72024d": "Session 凭证:", + "browser_clients_can_stay_signed_in_using_f316279b": "浏览器环境下支持利用常规 Session Cookie 会话保持登录。", + "access_tokens_8de23550": "AccessToken 令牌:", + "for_backend_calls_and_third_party_integrations_dfd8bf0f": "供后台调用或第三方应用集成使用。客户端生成 API 访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。", + "use_either_of_the_following_request_headers_1e03b04b": "支持携带令牌的请求头格式(二选一):", + "2_user_and_authentication_endpoints_4a89d429": "2. 用户与认证接口", + "2_1_user_registration_4f332f85": "2.1 用户注册", + "endpoint_79a2369a": "接口:", + "description_3e633a20": "说明:", + "register_a_local_account_when_registration_is_938891dc": "注册本地账户(在后台注册开关开启状态下)。", + "parameter_9634fb08": "参数", + "required_11da9dc4": "必填", + "yes_b5141d3d": "是", + "username_must_be_unique_and_contain_no_e81c1e5c": "用户名,必须唯一且无空格。", + "password_at_least_8_characters_37cdc807": "密码,长度必须大于等于 8 位。", + "no_0c70665b": "否", + "nickname_defaults_to_the_username_when_omitted_676aae7e": "昵称。未传时默认与用户名一致。", + "2_2_password_login_271483ba": "2.2 密码登录", + "authenticate_with_a_username_and_password_establishing_a38ff252": "通过常规用户名密码进行登录校验,成功后建立 Session Cookie 会话。", + "username_1a3f0617": "用户名", + "password_a621ab60": "密码", + "2_3_sign_out_3252611d": "2.3 退出登录", + "delete_the_current_session_cookie_and_sign_c213ffd9": "销毁当前会话 Cookie 并退出登录状态。", + "2_4_get_profile_7b96110a": "2.4 获取个人资料", + "get_the_signed_in_account_s_basic_b0eb8cfa": "获取当前登录账户的基本数据模型(包含 ID、角色、昵称等)。", + "3_personal_access_token_endpoints_989f5d7c": "3. 个人访问令牌 (AccessToken) 接口", + "access_token_management_requires_a_session_login_42d91543": "AccessToken 管理相关接口均要求通过 Session 登录后调用,支持普通用户权限。", + "3_1_list_tokens_9840c227": "3.1 获取令牌列表", + "list_the_current_user_s_tokens_with_c34d2210": "查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。", + "3_2_create_an_access_token_e53bd784": "3.2 新建访问令牌", + "parameters_ba89685c": "参数:", + "name_token_name_is_admin_false_4f3e4868": "{\"name\": \"token名称\", \"is_admin\": false}", + "generate_an_access_token_the_response_includes_70552390": "生成一个全新访问令牌。返回体中包含一次性明文 Token,切勿遗失。", + "optional_default_210b9d8f": "(可选,默认", + "grant_administrator_privileges_to_the_token_only_2292cad8": "):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问", + "endpoints_815c88e5": " 端点。", + "successful_response_example_7783a39c": "成功返回样例:", + "3_3_revoke_delete_a_token_ae510f58": "3.3 撤销/删除令牌", + "permanently_delete_a_token_by_id_immediately_145287f6": "通过 ID 物理删除对应访问令牌,该令牌将立即失效。", + "3_4_rotate_a_token_secret_79209983": "3.4 轮换令牌密钥", + "replace_a_token_s_secret_and_return_d2caca89": "轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文 Token,令牌名称与 ID 保持一致。", + "4_public_configuration_and_administration_17ebdbd5": "4. 公共配置与管理接口", + "4_1_public_system_configuration_2be912e4": "4.1 公共系统配置", + "get_publicly_visible_configuration_keys_and_values_bc7bff66": "无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。", + "response_example_d3087445": "返回数据结构样例:", + "4_2_system_configuration_crud_administrator_2ce66c8b": "4.2 系统配置项 CRUD (管理员)", + "manage_system_configs_dynamically_through_an_administrator_acdcdb99": "用于在后台对 `system_configs` 配置进行动态变更,要求管理员权限会话调用。", + "list_configuration_3d58680b": "获取配置列表:", + "create_a_configuration_entry_1d995264": "新建配置项:", + "update_a_configuration_value_2a0b0b76": "修改指定配置值:", + "delete_a_configuration_entry_1b77a6ea": "删除配置项:", + "1_quick_start_ef8fbb58": "1. 快速开始", + "a_guide_to_the_general_purpose_full_9cd8c987": "为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明", + "architecture_e992b65c": "架构底座:", + "go_gin_gorm_redis_asynq_backend_react_d5fe02d3": "Go (Gin + GORM + Redis + Asynq) 后端 + React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端", + "authentication_d36b345b": "认证体系:", + "local_username_password_registration_and_login_plus_583ab638": "支持本地常规账号密码注册登录 + 第三方自定义 OIDC (OAuth2) 认证源绑定", + "access_tokens_b7b856d3": "访问令牌:", + "personal_developer_access_tokens_api_keys_for_4b407149": "提供开发者个人 AccessToken (API Key),用于通过 Http Header 鉴权直接调用系统 API", + "observability_ea0cc1bf": "可观测性:", + "zap_structured_logging_and_opentelemetry_distributed_tracing_9fd6da9f": "集成 Zap 结构化日志与 OpenTelemetry 全链路 Tracing 追踪", + "2_authentication_and_security_settings_76967059": "2. 身份认证与安全设置", + "the_platform_combines_authentication_methods_to_support_bc9f1772": "平台采用混合式身份认证,满足不同的部署和业务场景需求:", + "2_1_username_and_password_authentication_97184f8f": "2.1 常规账号密码认证", + "registration_and_password_login_c5bd6cb4": "自主注册与密码登录:", + "users_can_register_and_create_sessions_with_21883e04": "支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用", + "salted_hashing_dc2a9b63": "高强度加盐哈希存储。", + "system_switches_53741557": "系统开关控制:", + "administrators_can_disable_password_registration_or_login_a2648e62": "管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。", + "2_2_third_party_oidc_providers_f14083c8": "2.2 第三方 OIDC 认证源", + "users_can_link_external_accounts_from_their_c08d3f5a": "用户可以在个人资料页面关联绑定外部授权账户:", + "open_4540d74d": "进入 ", + "settings_profile_32835b0d": "设置 / 个人资料", + "message_a173cce8": " 页面。", + "under_linked_accounts_view_existing_connections_or_5151eb9b": "在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC 认证源直接触发 OAuth2 绑定流。", + "after_linking_use_the_oidc_login_button_6095b15d": "绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。", + "3_integrating_personal_access_tokens_af2647b5": "3. 个人访问令牌 (AccessToken) 接口对接", + "personal_access_tokens_allow_developers_and_third_9a242a0e": "为便于开发者或第三方工具直接调用系统 API,平台提供个人访问令牌管理功能。", + "3_1_token_generation_and_storage_2b78c681": "3.1 令牌生成与存储规范", + "one_time_plaintext_display_fd13912a": "一次性明文展示:", + "a_newly_generated_plaintext_token_such_as_57eb11a1": "创建令牌时生成的随机明文 Token 值 (形如", + "is_displayed_only_once_copy_and_save_8d378c8a": ") 仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。", + "secure_hash_storage_1ce392ff": "安全哈希存储:", + "only_a_sha_256_hash_of_the_dbf434b1": "数据库仅存储 Token 的 SHA-256 哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。", + "3_2_calling_apis_with_headers_fccd7b0d": "3.2 携带 Header 进行 API 调用", + "use_your_saved_plaintext_token_with_either_3af53a05": "您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种 Http 请求头携带方式之一:", + "method_1_authorization_bearer_header_2a9c2a6c": "方式一:Authorization Bearer 头", + "method_2_custom_x_access_token_header_4548169f": "方式二:X-Access-Token 自定义头", + "4_dynamic_system_configuration_b3bf8c49": "4. 动态系统配置项", + "the_built_in_key_value_configuration_module_713f6d05": "平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:", + "cached_reads_35828322": "缓存读取加速:", + "configuration_is_read_from_the_database_through_3e9f6a34": "配置加载基于 GORM 读取数据库,并辅以 Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。", + "core_system_configuration_keys_6dc02be8": "核心系统配置项说明:", + "platform_display_name_116219b1": ":平台展示名称", + "password_login_switch_153eaa45": ":密码登录开关", + "user_registration_switch_827b5b21": ":用户自主注册开关", + "maximum_tokens_per_regular_user_default_5_5dd6d9e7": ":普通用户创建令牌的最大数限制 (默认5)", + "5_background_tasks_and_scheduling_7142ce96": "5. 异步任务与定时调度", + "cobra_provides_separate_commands_so_worker_services_1bbae2df": "项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker 服务解耦复杂计算或高延迟IO:", + "scheduled_dispatch_scheduler_command_e79062ad": "定时任务派发 (CMD scheduler):", + "enqueues_tasks_in_redis_according_to_their_694e5985": "负责按 Cron 表达式配置,定时将待执行任务推送到 Redis 队列中。", + "workers_with_queue_priorities_worker_command_faa970b1": "多优先级 Worker (CMD worker):", + "asynq_consumes_tasks_by_priority_and_executes_69d82251": "基于 Asynq 驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。", + "6_distributed_tracing_and_structured_logs_e8971cad": "6. 链路追踪与结构化日志", + "the_platform_integrates_monitoring_components_for_observability_2b1da50f": "为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:", + "tracing_context_is_propagated_automatically_gin_requests_0f549cce": "自动传递 Tracing 上下文,所有经由 Gin 中间件、外部请求或 GORM 数据库的事务操作都将带有全局唯一的 Span,用于排查链路耗时或调用异常。", + "zap_structured_logs_1c9725e8": "Zap 结构化日志:", + "standardizes_backend_console_and_log_output_as_16970757": "将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki 等日志收集分析工具无缝对接。", + "last_updated_value0_87a896df": "最后更新:{value0}", + "back_to_home_d66f4359": "返回首页", + "sections_fcb2696e": " 个内容", + "last_updated_a83cd480": "最后更新:", + "copied_8f6f8d97": "已复制", + "copy_markdown_f9c71ee4": "复制 Markdown", + "table_of_contents_52daa71e": "目录", + "questions_about_this_content_feb7d44a": "对这些内容有疑问?", + "contact_support_4cac90c8": "联系支持", + "1_information_we_collect_af0db091": "1. 详细信息收集说明", + "we_collect_only_the_information_necessary_to_72f6e528": "我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:", + "1_1_authentication_information_60199ee9": "1.1 身份鉴权信息:", + "when_you_register_locally_or_sign_in_acdd6bd1": "当您通过本地账号注册或绑定第三方 OIDC 认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方 OpenID 标识符。", + "we_do_not_require_a_phone_number_3540dfca": "我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。", + "1_2_service_and_api_logs_46c9a04d": "1.2 服务与接口日志信息:", + "for_system_security_and_auditing_we_automatically_89737fdd": "为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、个人访问令牌 API 调用历史记录、User-Agent(浏览器/设备/请求工具类型)。", + "2_data_storage_and_security_7cc5cdef": "2. 数据存储与安全保护", + "we_recognize_the_importance_of_data_security_cd3bd4d9": "我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:", + "storage_security_a33cfbef": "存储安全:", + "user_data_is_stored_in_a_dedicated_132af7bb": "用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。", + "cryptography_9a15a88b": "加密技术:", + "sensitive_password_and_personal_access_token_hashes_9f724cd8": "敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API 数据传输链路强制使用 SSL/TLS 进行安全加密。", + "access_control_3471eafa": "访问控制:", + "we_apply_the_principle_of_least_privilege_79328c28": "我们实行严格的最小权限原则(Least Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。", + "3_how_we_use_information_41b4d6ad": "3. 信息使用规范", + "collected_information_is_used_only_for_the_4868b538": "我们收集的信息将仅用于以下目的:", + "identification_481b07f4": "身份识别:", + "to_identify_your_registered_account_and_display_8c559094": "用于确认您的注册身份,展示您的个人中心及设置页面数据。", + "service_functions_c5834f20": "业务功能:", + "to_identify_and_process_api_authentication_requests_d48a36a2": "用于识别并处理您的个人访问令牌 API 鉴权指令。", + "security_e7132437": "安全风控:", + "to_analyze_ip_addresses_and_activity_logs_5210836a": "利用 IP 及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。", + "prohibited_uses_b13fde94": "禁止用途:", + "we_will_8856fb95": "我们承诺", + "never_c1d7d603": "绝不", + "sell_your_data_to_third_parties_or_7bb4d77a": "将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。", + "4_information_sharing_and_disclosure_c034d295": "4. 信息共享与对外披露", + "4_1_sharing_principles_b89eb890": "4.1 共享原则:", + "we_do_not_share_personal_information_with_ffcf1e8f": "除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:", + "with_your_prior_explicit_authorization_or_consent_0fa05878": "事先获得您的明确授权或同意;", + "when_required_by_applicable_laws_legal_proceedings_cb547dd6": "根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。", + "4_2_transfer_and_public_disclosure_383d35be": "4.2 转让与公开披露:", + "we_do_not_transfer_your_personal_information_e3339921": "我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。", + "5_your_rights_and_data_management_08999326": "5. 您的权利与数据管理", + "under_the_personal_information_protection_law_of_94ea405e": "依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:", + "5_1_access_and_management_854d0793": "5.1 查阅与管理权:", + "you_may_sign_in_at_any_time_8727c5be": "您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的 AccessToken (API 密钥)。", + "5_2_deletion_and_account_closure_9d1e7aba": "5.2 删除与注销权:", + "if_you_stop_using_the_service_you_24c642c6": "若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。", + "6_policy_updates_and_notices_444076f5": "6. 政策更新与通知", + "this_privacy_policy_may_change_as_the_2a270464": "随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。", + "we_will_prominently_announce_material_changes_for_210fbd3b": "当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。", + "1_agreement_and_service_overview_511c6782": "1. 缔约申明与服务综述", + "1_1_parties_d2bf79cd": "1.1 缔约主体:", + "these_terms_of_service_the_agreement_govern_1e6754fc": "本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。", + "1_2_read_carefully_dfde346e": "1.2 审慎阅读:", + "this_system_is_a_general_purpose_full_f1d395fe": "本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC 接入、API Key 令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。", + "1_3_agreement_contents_f05cebc0": "1.3 协议构成:", + "the_agreement_includes_these_terms_and_any_43e244b6": "本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。", + "2_service_definition_and_nature_61db87f8": "2. 服务定义与性质界定", + "your_responsibility_527cbdbf": "责任自担:", + "the_entity_deploying_and_operating_a_customized_047c6a8f": "关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。", + "3_registration_and_account_use_cfab1555": "3. 账号注册与使用规范", + "3_1_accounts_2631c114": "3.1 账号体系:", + "users_may_register_through_the_frontend_or_e527ef52": "用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方 OIDC 认证源进行登录关联。", + "3_2_password_and_token_security_80dd80d9": "3.2 密码及令牌安全责任:", + "password_security_7a29e44e": "密码安全:", + "keep_your_account_password_secure_eb9adf10": "您应妥善保管您账户的登录密码。", + "api_token_security_34af066f": "API Token 安全:", + "a_personal_access_token_api_key_represents_6215b3b0": "个人生成的 AccessToken (API Key) 代表您账户的完整调用权限。", + "you_are_responsible_for_data_loss_or_0c9acc88": "因您保管不善导致 Token 泄漏而造成的一切数据丢失或系统损失,均由您自行承担。", + "4_prohibited_user_conduct_bd5628b6": "4. 用户行为准则(负面清单)", + "when_using_this_system_you_must_comply_1e54356c": "您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。", + "the_following_activities_are_strictly_prohibited_5b57de7f": "严禁利用本平台从事以下活动(“红线条款”):", + "endangering_national_security_4a160c4e": "危害国家安全:", + "opposing_constitutional_principles_endangering_national_security_disclosing_bdfb8814": "反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;", + "illegal_information_services_c97748bb": "非法信息服务:", + "hacking_tools_ddos_attack_services_server_brute_686746c4": "黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;", + "pornography_gambling_and_drugs_fdb44a54": "黄赌毒关联:", + "creating_copying_publishing_or_distributing_obscene_pornographic_ec8c0fdb": "制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;", + "intellectual_property_infringement_cc10b950": "侵犯知识产权:", + "selling_pirated_software_or_media_illegal_game_3f0f4159": "销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;", + "other_unlawful_information_4085c17c": "其他违法信息:", + "spreading_rumors_promoting_cults_or_superstition_insulting_a062981a": "涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。", + "enforcement_9c7cfd68": "处理规则:", + "if_these_rules_are_violated_the_platform_86df3b18": "一旦发现您违反上述规定,平台运维主体有权不经通知", + "permanently_suspend_your_account_block_all_access_b9f04a42": "立即永久封禁您的账号、拦截所有 AccessToken 请求,并依法向公安机关、网安部门移交相关线索。", + "5_disclaimer_and_force_majeure_be649f43": "5. 免责声明与不可抗力", + "5_1_general_disclaimer_30d01f0d": "5.1 基础免责:", + "this_development_platform_is_provided_as_is_23bc2b55": "本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。", + "5_2_service_interruptions_16558fa8": "5.2 技术服务中断:", + "the_platform_is_not_liable_for_interruptions_405b1c95": "对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:", + "natural_disasters_including_typhoons_earthquakes_tsunamis_and_1bbbfb21": "自然灾害(台风、地震、海啸、洪水等);", + "government_actions_cybersecurity_law_changes_or_administrative_2b7322f5": "政府行为、网络安全法律法规调整或行政命令;", + "telecommunication_faults_or_damage_to_data_center_259a542f": "电信部门线路技术故障、机房海底光缆损毁;", + "data_corruption_or_downtime_caused_by_hacking_68f2e04f": "黑客入侵、勒索病毒感染导致的数据损毁或宕机。", + "6_governing_law_and_disputes_b9663c86": "6. 法律适用与争议解决", + "6_1_governing_law_ff2afc5e": "6.1 法律适用:", + "the_formation_performance_interpretation_and_resolution_of_c1510775": "本协议的订立、执行、解释及争议的解决均适用", + "the_laws_of_the_people_s_republic_5d5c80d6": "中华人民共和国法律", + "excluding_the_conflict_of_laws_rules_of_b235c4e3": "(不包括港澳台地区冲突法)。", + "6_2_dispute_resolution_44e871de": "6.2 争议解决:", + "disputes_should_first_be_resolved_through_friendly_1ccf4610": "若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。", + "failed_to_load_value0_7f5a06a3": "{value0} 加载失败", + "load_failed_d1d04482": "加载失败", + "no_data_497c8569": "暂无数据", + "loading_d04fcbda": "加载中", + "saved_e05ddf99": "保存成功", + "save_failed_6309a3bb": "保存失败", + "unknown_error_13a46616": "未知错误", + "deleted_5223f91b": "删除成功", + "delete_failed_c228558c": "删除失败", + "configuration_list_53052147": "配置列表", + "confirm_delete_2d61cf17": "确认删除?", + "this_cannot_be_undone_this_will_permanently_8c896756": "此操作无法撤销。这将永久删除该配置。", + "cancel_2cd0f3be": "取消", + "confirm_delete_a3ea3c17": "确认删除", + "configuration_info_7f69a860": "配置信息", + "select_a_configuration_to_view_details_ec4949ab": "请选择配置查看详情", + "updating_e1d9dd03": " 更新中", + "update_3055a035": "更新", + "actions_ed31fbb4": "操作", + "enter_password_728a7b60": "请输入密码", + "for_your_account_security_please_enter_the_28fc0bbd": "为了您的账户安全,请输入6位认证密码进行验证", + "verifying_6c128e0a": "验证中...", + "confirm_36f33ada": "确认", + "profile_b94d13a4": "个人资料", + "manage_your_account_information_and_preferences_cced83b4": "管理您的个人账户信息及个性化配置", + "user_guide_c83b6b0a": "使用文档", + "api_integration_and_everyday_operation_guides_281269a1": "学习如何集成 API 及日常操作帮助指南", + "system_settings_68ea5dd4": "系统设置", + "manage_registration_login_and_authentication_provider_security_f378ed37": "管理登录注册开关与认证源安全策略", + "global_system_configuration_1eb8931e": "全局系统配置", + "manage_core_platform_runtime_settings_341e4182": "动态管理平台运行时核心配置参数", + "user_permissions_8e8b1d33": "用户权限管理", + "view_and_manage_registered_users_and_their_db533b7c": "集中查询并管理系统注册用户的启用状态", + "background_task_scheduling_408172d1": "后台任务调度", + "dispatch_and_monitor_background_and_scheduled_tasks_83967fc9": "分发与观测系统异步定时任务的执行情况", + "system_logs_366ada1d": "系统日志", + "view_background_task_logs_and_system_status_24fd6a7d": "查看异步任务执行日志与系统运行状态详情", + "a_modern_extensible_administration_platform_with_ready_0d95c0c1": "通用的、现代化的后台管理系统脚手架, 开箱即用、完整基建、极易扩展,助您快速构建企业级应用。", + "get_started_715ee9dc": "开始使用", + "hide_tip_738f4b27": "隐藏提示", + "quick_navigation_05deefcb": "常用功能导航", + "open_fee3b5a5": "立即跳转 ", + "open_df42ebc3": "立即进入 ", + "administration_console_ceb0c289": "后台管理控制台", + "live_origin_error_page_preview_a2a12823": "源站错误页实时预览", + "html_editor_19f0d7b7": "HTML 编辑器", + "bytes_f5d87a8b": " 字节", + "leave_empty_for_the_built_in_default_29ffce9f": " · 空则使用内置默认", + "leave_empty_to_use_the_built_in_e57252bd": "留空使用内置默认模板…", + "drag_to_resize_b5154511": "拖动调整宽度", + "live_preview_a76fdaad": "实时预览", + "preview_13d61fea": "预览", + "for_example_github_1061e2e4": "例如: github", + "for_example_github_login_1e0c113c": "例如: GitHub 登录", + "https_or_leave_empty_a0d86d9b": "https://... 或留空", + "missing_configuration_value0_31b43fa6": "缺少配置项: {value0}", + "missing_configuration_file_access_whitelist_6dd80936": "缺少配置项: file_access_whitelist", + "directory_visibility_configuration_json_string_format_url_8e3c0671": "目录显示配置(JSON 字符串,格式为 {url: enabled})", + "missing_configuration_login_session_ttl_hours_d6f433c2": "缺少配置项: login_session_ttl_hours", + "server_address_d3716cc5": "服务器地址", + "platform_display_name_4b656edf": "系统平台的展示名称", + "for_example_openflare_28e07341": "例如: OpenFlare", + "for_example_https_example_com_9cf31711": "例如: https://example.com", + "allow_search_engines_to_crawl_and_index_59eff8b5": "允许搜索引擎抓取与收录", + "for_example_smtp_example_com_834569b4": "例如: smtp.example.com", + "for_example_587_or_465_1bd015a5": "例如: 587 或 465", + "for_example_sender_example_com_1795f7c6": "例如: sender@example.com", + "for_example_receiver_example_com_ade0ff8b": "例如: receiver@example.com", + "for_example_login_email_c7212e1f": "例如: login_email", + "for_example_login_verification_email_9d28f166": "例如: 登录验证码邮件", + "for_example_openflare_login_verification_code_0925e8d4": "例如: OpenFlare 登录验证码", + "for_example_your_code_is_code_valid_44cf273f": "例如: 包含变量:{{.Code}},5分钟内有效", + "h3_message_title_h3_p_message_content_20c91d04": "

    正文标题

    内容段落,可用变量 {{.Code}}

    ", + "register_a_user_curl_x_post_https_4ae682b0": "# 注册用户\ncurl -X POST https://api.example.com/api/v1/auth/register \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"username\": \"developer\",\n \"email\": \"dev@example.com\",\n \"password\": \"secure_password\"\n }'", + "quick_start_42fbb067": "# 快速开始", + "developer_friendly_40c2a4ce": "开发者友好,", + "quick_to_integrate_18cbee8a": "极速集成上手", + "standard_restful_apis_complete_typescript_types_detailed_6369a0e2": "标准化的 RESTful API 接口,完整的 TypeScript 类型定义,详细的文档和 Swagger UI。无论使用什么编程语言,只需几行代码,即可快速集成。", + "restful_api_with_clear_understandable_semantics_1fa9d6f5": "RESTful API,语义清晰,易于理解", + "openapi_swagger_documentation_ccbcbc84": "OpenAPI / Swagger 接口文档", + "developer_friendly_typescript_type_definitions_910a7909": "TypeScript 类型定义,开发友好", + "code_samples_and_integration_guides_9e998a5a": "丰富的代码示例和集成指南", + "an_open_source_platform_for_managing_edge_317d8f34": "开源边缘节点与反向代理管理平台。基于 Go + React 构建,统一管理 OpenResty 节点、网站规则与 WAF 配置。", + "product_aea82737": "产品", + "dashboard_0853bf3c": "仪表盘", + "personal_c2952a5f": "个人设置", + "development_9ec42862": "开发", + "source_code_606a4e58": "源代码", + "community_5bcd0ddc": "社区", + "discussions_3cef912c": "讨论", + "privacy_policy_e464b41f": "隐私政策", + "terms_of_service_9303687a": "服务条款", + "built_for_customization_ff3cf147": "为二次开发而生", + "a_modern_administration_platform_116688ff": "通用的、现代化的后台管理系统脚手架", + "ready_to_use_infrastructure_that_is_easy_77776634": "开箱即用、完整基建、极易扩展,助您快速构建企业级应用。", + "get_started_d1f9c450": "立即开始", + "learn_more_ca66c2da": "了解更多", + "ready_to_use_c58cfea2": "开箱即用", + "highly_extensible_9dfa6c10": "高度可扩展", + "production_infrastructure_08c3a5e6": "工业级基建", + "good_fortune_b3541887": "福", + "retry_b8784c8d": "重试", + "an_unknown_error_occurred_please_try_again_79a9f98a": "发生未知错误,请稍后重试", + "view_error_details_9e7e4e37": "查看详细错误信息", + "unknown_error_e83a8ca8": "发生未知错误", + "fetching_data_9ac5e428": "正在获取活动数据...", + "type_and_press_enter_to_add_e30b7b5f": "输入后按 Enter 添加", + "remove_value0_30c2158a": "移除 {value0}", + "fnv_1a_32_bit_function_fnv1a_str_a1690b96": "\n// FNV-1a 32-bit\nfunction fnv1a(str) {\n let h = 2166136261 >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619) >>> 0;\n }\n return h;\n}\nfunction fnv1aResume(state, str) {\n let h = state >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619) >>> 0;\n }\n return h;\n}\n\n// xorshift32 PRNG → hex string\nfunction prngFromHash(seed, len) {\n let s = seed >>> 0, r = '';\n while (r.length < len) {\n s ^= (s << 13) >>> 0;\n s ^= (s >>> 17) >>> 0;\n s ^= (s << 5) >>> 0;\n s = s >>> 0;\n r += s.toString(16).padStart(8, '0');\n }\n return r.slice(0, len);\n}\n\n// SHA-256 via SubtleCrypto (available in workers)\nasync function sha256Hex(input) {\n const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));\n return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Solve one puzzle\nasync function solvePuzzle(token, index, size, difficulty) {\n const tFnv = fnv1a(token);\n const saltSeed = fnv1aResume(tFnv, String(index + 1));\n const targetSeed = fnv1aResume(saltSeed, 'd');\n const salt = prngFromHash(saltSeed, size);\n const target = prngFromHash(targetSeed, difficulty);\n\n for (let nonce = 0; nonce < 10_000_000; nonce++) {\n if ((await sha256Hex(salt + nonce)).startsWith(target)) return nonce;\n }\n throw new Error('无法在合理范围内求解第 ' + (index + 1) + ' 个难题');\n}\n\n// Entry point — receive task from main thread\nself.onmessage = async ({ data }) => {\n const { token, count, size, difficulty } = data;\n try {\n const t0 = performance.now();\n const solutions = [];\n for (let i = 0; i < count; i++) {\n solutions.push(await solvePuzzle(token, i, size, difficulty));\n }\n const elapsed = ((performance.now() - t0) / 1000).toFixed(3);\n self.postMessage({ type: 'done', solutions, elapsed });\n } catch (err) {\n self.postMessage({ type: 'error', message: err.message });\n }\n};\n", + "worker_execution_failed_1877e35b": "Worker 执行失败", + "failed_to_fetch_the_verification_challenge_95553133": "获取人机验证难题失败", + "cap_starting_verification_pow_solver_657f3af5": "[Cap] 人机验证 PoW 求解开始", + "challenge_count_value0_ea1ffc80": " 难题数量 (count) : {value0}", + "difficulty_value0_f104cb0d": " 验证难度 (difficulty): {value0}", + "salt_size_value0_4f2bae5d": " 盐值长度 (size) : {value0}", + "cap_verification_pow_solver_completed_e1f79fd5": "[Cap] 人机验证 PoW 求解完成", + "worker_duration_value0_s_8de32777": " Worker 耗时 : {value0}s", + "total_duration_including_communication_value0_s_004a6894": " 总耗时(含通信) : {value0}s", + "verification_failed_d3925ab2": "人机验证失败", + "doctype_html_html_lang_en_head_meta_3e4f5ecb": "\n\n\n\n \n \n 离线页 | OpenFlare\n \n\n\n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    OFFLINE FALLBACK
    \n

    离线联系站长

    \n

    \n 源站网络临时不可达。离线 Service Worker 已拦截请求,您可以使用下方联系方式取得支持。\n

    \n
    \n
    \n 站长邮箱\n admin@example.com\n
    \n
    \n 服务监控\n status.example.com\n
    \n
    \n
    \n
    \n
    \n \n \n \n OpenFlare Service Worker\n
    \n OFFLINE MODE\n
    \n
    \n\n\n", + "minimal_white_5636e8a9": "极简白底", + "line_topology_22ad450c": "线框拓扑", + "bauhaus_5cc69781": "包豪斯", + "doctype_html_html_lang_en_head_meta_42a4f2da": "\n\n\n\n \n \n 离线页 | OpenFlare\n \n\n\n
    \n
    \n
    \n \n SERVICE WORKER OFFLINE CACHE\n
    \n

    网络中断或源站不可达

    \n

    \n 系统已自动启用 Service Worker 兜底保护。如需紧急联系站长,请通过以下渠道:\n

    \n
    \n
    \n Email Support\n admin@example.com\n
    \n
    \n Status Page\n status.example.com\n
    \n
    \n
    \n
    \n
    \n \n \n \n OpenFlare Edge\n
    \n FALLBACK ACTIVE\n
    \n
    \n\n\n", + "doctype_html_html_lang_en_head_meta_505c1308": "\n\n\n\n \n \n 离线页 | OpenFlare\n \n\n\n
    \n
    OFFLINE CONTACT
    \n

    站点暂不可用,联系站长

    \n

    \n 当前无法连接至源站。页面已开启 Service Worker 离线保护,您可以通过以下方式与站长取得联系。\n

    \n
    \n
    \n \n 联系邮箱:admin@example.com\n
    \n
    \n \n 服务状态页:status.example.com\n
    \n
    \n
    \n \n \n \n OpenFlare Protection\n
    \n
    \n\n\n", + "status_code_tag_must_not_be_empty_21235ebf": "状态码标签不能为空", + "invalid_status_code_range_value0_8983e96e": "无效状态码区间: {value0}", + "invalid_status_code_value0_0f206e48": "无效状态码: {value0}", + "status_code_range_endpoints_are_reversed_value0_d92c2784": "状态码区间左右端点反序: {value0}", + "status_code_must_be_between_value0_value1_c5b04822": "状态码须在 {value0}–{value1}: {value2}", + "invalid_status_code_tag_d16e85d7": "无效状态码标签", + "add_at_least_one_status_code_tag_b81b2696": "请至少添加一个状态码标签", + "the_expanded_status_code_list_is_empty_47245f34": "状态码展开结果为空", + "request_canceled_5892f9b1": "请求已被取消", + "insufficient_permissions_complete_verification_and_try_again_73719073": "权限不足,请过盾后重试", + "the_requested_resource_was_not_found_aecc0681": "请求的资源不存在", + "request_parameter_validation_failed_60ef7c51": "请求参数验证失败", + "too_many_requests_retry_in_value0_seconds_ab5e78f1": "请求过于频繁,请 {value0} 秒后重试", + "request_rate_limit_ad4f0f8b": "请求频率限制", + "internal_server_error_try_again_later_4f0dd7e9": "服务器内部错误,请稍后重试", + "cannot_connect_to_the_server_check_that_5551f4fc": "无法连接到服务器,请确认后端服务已启动", + "network_request_failed_8407ceab": "网络请求失败", + "request_canceled_manually_7ba622e6": "请求已被手动取消", + "all_requests_canceled_72f8fa9a": "所有请求已被取消", + "network_connection_failed_check_your_network_cbf47b07": "网络连接失败,请检查您的网络", + "request_timed_out_try_again_later_6c3465f6": "请求超时,请稍后重试", + "unauthorized_sign_in_first_466879f5": "未授权,请先登录", + "insufficient_permissions_5e3c8a26": "权限不足", + "value0_must_be_a_valid_number_1d79f887": "「{value0}」必须是有效的数字", + "value0_must_be_true_or_false_23cce68e": "「{value0}」必须是 true 或 false", + "value0_must_not_be_empty_8bf3a929": "「{value0}」不能为空", + "request_canceled_282c4cfb": "请求已取消", + "base64_encoding_is_not_supported_in_this_7642d848": "当前环境不支持 Base64 编码", + "rate_limit_retry_later": "请求过于频繁,请稍后重试", + "list_separator": "、", + "sentence_end": "。", + "task_notification_template_example": "{\"title\":\"任务完成\",\"content\":\"{{task_name}}: {{task_status}}\",\"level\":\"INFO\"}", + "ui_totalRequests": "总请求数", + "ui_totalVisits": "总访问量", + "ui_bandwidthServed": "已传输流量", + "ui_requestsOverTime": "请求趋势", + "ui_requestsByDevice": "按设备类型统计请求", + "ui_statusCodes": "状态码", + "ui_topPaths": "热门路径", + "ui_topHosts": "热门主机", + "ui_topIps": "热门 IP", + "ui_topBrowsers": "热门浏览器", + "ui_topOperatingSystems": "操作系统", + "ui_topUserAgents": "热门 User-Agent", + "ui_deviceTypes": "设备类型", + "ui_fileHash": "哈希(SHA-256)", + "ui_pathStyle": "路径寻址模式", + "ui_requestHeaders": "请求头", + "ui_barkApp": "Bark 应用", + "ui_administrator": "管理员", + "ui_nodeLabel": "节点:", + "ui_payload": "任务参数", + "ui_selectAll": "全选", + "ui_production": "生产版本", + "ui_allDeployments": "所有部署", + "ui_remoteUrl": "远程 URL", + "ui_githubRelease": "GitHub Release", + "ui_releaseTag": "发布标签", + "ui_originHostHeader": "源站 Host 请求头", + "ui_globalRuleGroup": "全局规则组", + "ui_zone": "域名区域", + "ui_pageNotFound": "页面不存在", + "ui_jsonBody": "JSON 请求体", + "ui_openTelemetryTracing": "OpenTelemetry 链路追踪:", + "ui_clientId": "客户端 ID", + "ui_clientSecret": "客户端密钥", + "ui_scopes": "授权范围", + "ui_copyright": "© 2026 Modern Platform. 保留所有权利。", + "ui_technologyStack": "技术栈", + "ui_readyFor": "准备就绪", + "ui_development": "开发", + "ui_features": "功能", + "ui_modern": "现代", + "ui_versionLabel": "版本", + "ui_builtAt": "构建时间", + "ui_breadcrumb": "面包屑导航", + "ui_more": "更多", + "ui_previousSlide": "上一张", + "ui_nextSlide": "下一张", + "ui_closeLabel": "关闭", + "ui_imageCrop": "裁剪图片", + "ui_pagination": "分页导航", + "ui_previousPage": "上一页", + "ui_previousLabel": "上一页", + "ui_nextPage": "下一页", + "ui_nextLabel": "下一页", + "ui_morePages": "更多页面", + "ui_sidebarLabel": "侧边栏", + "ui_mobileSidebarDescription": "显示移动端侧边栏。", + "ui_toggleSidebar": "切换侧边栏", + "ui_loadingLabel": "加载中", + "origin_error_page_minimalist": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n

    {{status}}

    \n

    \n 源站无法访问,请稍后重试。如果问题持续存在,请联系网站管理员。\n

    \n

    {{host}}

    \n
    \n \n \n \n OpenFlare\n
    \n
    \n\n\n", + "origin_error_page_line_style": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n
    \n
    {{status}}
    \n
    源站不可用
    \n
    \n\n
    \n \n \n \n \n \n \n \n \n \n\n \n \n\n \n CLI\n 客户端\n 请求已发送\n\n \n \n OPENFLARE\n 边缘代理\n\n \n \n \n 源站\n 不可用\n \n
    \n\n
    \n \n \n \n OpenFlare\n
    \n
    \n\n \n\n\n", + "origin_error_page_baohaus": "\n\n\n\n \n \n {{status}} | OpenFlare\n \n\n\n
    \n
    \n
    \n
    {{status}}
    \n\n
    \n \n \n \n OpenFlare\n
    \n
    \n\n
    \n

    源站不可用

    \n

    \n 服务器从源站收到了无效响应。\n

    \n 形式追随功能: 连接已中断,请检查源站状态。\n

    \n
    \n
    错误码: {{status}}
    \n
    主机: {{host}}
    \n
    代理: OPENFLARE_EDGE
    \n
    --:--:-- UTC
    \n
    \n
    \n\n
    \n
    \n
    \n
    \n
    \n
    \n\n \n\n\n", + "ui_accountId": "账户 ID", + "ui_endpointLabel": "服务地址", + "ui_regionLabel": "区域", + "ui_bucketLabel": "存储桶", + "ui_accessKeyId": "访问密钥 ID", + "ui_secretAccessKey": "访问密钥", + "ui_isoCode": "ISO 代码", + "ui_serverUrl": "服务器 URL", + "ui_commandPalette": "命令面板", + "ui_commandSearch": "搜索要执行的命令…", + "ui_botLabel": "机器人", + "ui_unknownLabel": "未知", + "ui_otherLabel": "其他", + "ui_toastRegion": "通知", + "ui_closeToast": "关闭通知", + "node_type_edge": "边缘节点", + "node_type_relay": "中继节点", + "node_type_tunnel": "隧道节点", + "system_metric_day": "{count}天", + "system_metric_hour": "{count}小时", + "system_metric_minute": "{count}分钟", + "system_metric_second": "{count}秒", + "system_metric_unknown": "未知", + "system_metric_none": "无" + } +} diff --git a/frontend/messages/fragments/search-keywords.en.json b/frontend/messages/fragments/search-keywords.en.json new file mode 100644 index 00000000..e1f7b6cf --- /dev/null +++ b/frontend/messages/fragments/search-keywords.en.json @@ -0,0 +1,36 @@ +{ + "layout": { + "search": { + "keywords": { + "home": "home|dashboard", + "docs-how-to-use": "docs|how to|tutorial|help", + "console-nodes": "node|tunnel", + "console-proxy-routes": "route|proxy", + "console-websites": "website|domain", + "console-certificates": "certificate|ssl|tls|https", + "console-dns-accounts": "dns|dns account|cloudflare|aliyun|tencent", + "console-origins": "origin|backend", + "console-responses": "502|503|5xx|Service Worker|error page", + "console-waf": "waf|security", + "console-ip-groups": "ip|ip group", + "console-rate-limits": "rate limit|limit_conn|limit_rate", + "console-pages": "pages|cdn|static", + "console-config-versions": "version|config", + "console-access-logs": "log|logs", + "console-apply-logs": "apply|log|logs", + "console-performance": "performance", + "settings": "settings|preferences", + "settings-profile": "profile|avatar", + "settings-appearance": "appearance|theme|dark|light", + "admin-settings": "admin|security|oidc|login", + "admin-system": "admin|system|configurations", + "admin-users": "admin|users|status", + "admin-tasks": "admin|tasks|scheduler|worker", + "admin-files": "admin|files|upload|s3", + "admin-database": "admin|database|sql|query|gorm", + "admin-push": "admin|push|mail|telegram|lark", + "admin-logs": "admin|logs|system log|terminal" + } + } + } +} diff --git a/frontend/messages/fragments/search-keywords.ru.json b/frontend/messages/fragments/search-keywords.ru.json new file mode 100644 index 00000000..cf3cbda8 --- /dev/null +++ b/frontend/messages/fragments/search-keywords.ru.json @@ -0,0 +1,36 @@ +{ + "layout": { + "search": { + "keywords": { + "home": "главная страница|панель управления", + "docs-how-to-use": "документы|как|руководство|помощь", + "console-nodes": "узел|туннель|node|nodes|edge|relay|tunnel", + "console-proxy-routes": "маршрут|прокси|proxy|routes", + "console-websites": "сайт|домен", + "console-certificates": "сертификат|ssl|tls|https", + "console-dns-accounts": "DNS|учетная запись DNS|cloudflare|aliyun|tencent", + "console-origins": "источник|сервер-источник|бэкенд|origin", + "console-responses": "502|503|5xx|Service Worker|страница ошибки", + "console-waf": "waf|безопасность", + "console-ip-groups": "ip|ip группа", + "console-rate-limits": "лимиты|скорость|ограничение|limit_conn|limit_rate", + "console-pages": "страницы|cdn|статические|pages|static", + "console-config-versions": "версия|конфигурация|config|versions", + "console-access-logs": "журнал|журналы", + "console-apply-logs": "применить|журнал|журналы", + "console-performance": "производительность", + "settings": "настройки|предпочтения", + "settings-profile": "профиль|аватар", + "settings-appearance": "внешний вид|тема|темный|светлый", + "admin-settings": "администратор|безопасность|oidc|логин", + "admin-system": "администратор|система|конфигурации", + "admin-users": "администратор|пользователи|статус", + "admin-tasks": "администратор|задачи|расписание|планировщик|worker", + "admin-files": "администратор|файлы|загрузить|s3", + "admin-database": "администратор|база данных|sql|запрос|gorm", + "admin-push": "администратор|уведомления|почта|push|mail|telegram|lark", + "admin-logs": "администратор|журналы|системный журнал|терминал" + } + } + } +} diff --git a/frontend/messages/fragments/search-keywords.zh-CN.json b/frontend/messages/fragments/search-keywords.zh-CN.json new file mode 100644 index 00000000..82303d91 --- /dev/null +++ b/frontend/messages/fragments/search-keywords.zh-CN.json @@ -0,0 +1,36 @@ +{ + "layout": { + "search": { + "keywords": { + "home": "home|主页|首页|dashboard|总览", + "docs-how-to-use": "docs|文档|使用|how to|tutorial|教程|help", + "console-nodes": "node|节点|边缘节点|中继|内网穿透|tunnel|服务器", + "console-proxy-routes": "route|规则|路由|代理|反向代理|proxy", + "console-websites": "website|domain|网站|域名|站点", + "console-certificates": "certificate|ssl|tls|证书|https|加密", + "console-dns-accounts": "dns|dns account|账号|域名解析|cloudflare|aliyun|tencent", + "console-origins": "origin|源站|后端|backend|服务器|负载均衡", + "console-responses": "响应页面|错误页|联系页|源站|502|503|5xx|Service Worker|离线兜底|error page", + "console-waf": "waf|防火墙|安全|security|拦截|规则", + "console-ip-groups": "ip|ip group|ip组|黑名单|白名单|访问控制", + "console-rate-limits": "限流|rate limit|limit_conn|limit_rate|并发|带宽", + "console-pages": "pages|静态托管|cdn|网站|部署|static", + "console-config-versions": "version|config|版本|发布|回滚|对比|部署", + "console-access-logs": "log|logs|访问日志|分析|流量|请求", + "console-apply-logs": "apply|log|logs|应用记录|配置下发|同步|部署历史", + "console-performance": "performance|性能|调优|优化|参数|连接|超时", + "settings": "settings|设置|偏好|preferences", + "settings-profile": "profile|资料|个人|我的|信息|avatar", + "settings-appearance": "appearance|外观|主题|theme|dark|light", + "admin-settings": "admin|管理员|系统设置|安全|security|oidc|login", + "admin-system": "admin|管理员|系统|配置|system|configurations", + "admin-users": "admin|管理员|用户|管理|users|status", + "admin-tasks": "admin|管理员|任务|异步|tasks|scheduler|worker", + "admin-files": "admin|管理员|存储|文件|files|upload|s3", + "admin-database": "admin|管理员|数据库|database|sql|query|gorm", + "admin-push": "admin|管理员|推送|通知|push|mail|telegram|lark", + "admin-logs": "admin|管理员|日志|logs|system log|terminal" + } + } + } +} diff --git a/frontend/messages/fragments/security.en.json b/frontend/messages/fragments/security.en.json index ecd3b1a4..0a0d676f 100644 --- a/frontend/messages/fragments/security.en.json +++ b/frontend/messages/fragments/security.en.json @@ -247,7 +247,7 @@ "title": "Automatic rule test result", "description": "Replay request logs against the current automatic configuration JSON.", "testing": "Testing...", - "summary": "Lookback {lookback} · {rules} rules · {matched} IPs matched", + "summary": "Lookback {lookback} · {rules, plural, one {# rule} other {# rules}} · {matched, plural, one {# IP matched} other {# IPs matched}}", "testedAt": "Tested at: {time}", "noMatch": "No IPs matched.", "empty": "No test result yet.", @@ -425,7 +425,8 @@ "history": { "source": { "manual_upload": "Local upload", - "manual_url": "URL import" + "manual_url": "URL import", + "remote_url": "Remote URL" }, "trigger": { "manual_upload": "Manual upload", @@ -623,7 +624,7 @@ "title": "Clean up access logs", "description": "Delete access logs older than the retention period. This cannot be undone.", "policy": "Retention policy", - "keepDays": "Keep last {days} days", + "keepDays": "Keep the last {days, plural, one {# day} other {# days}}", "custom": "Custom days", "customDays": "Custom retention days", "cleaning": "Cleaning...", diff --git a/frontend/messages/fragments/security.ru.json b/frontend/messages/fragments/security.ru.json new file mode 100644 index 00000000..bce4ab51 --- /dev/null +++ b/frontend/messages/fragments/security.ru.json @@ -0,0 +1,1101 @@ +{ + "waf": { + "title": "WAF", + "createRule": "Новое правило", + "ruleGroups": "Группы правил", + "ruleGroupsDesc": "Создавайте правила защиты WAF с помощью визуального представления. Глобальные правила всегда применяются к каждому сайту.", + "refresh": "Обновить", + "loading": "Загрузка групп правил WAF...", + "empty": "Групп правил пока нет. Система обычно автоматически создает глобальную группу.", + "created": "Правило создано", + "groupDeleted": "Группа правил удалена.", + "deleteTitle": "Удалить группу правил", + "deleteDesc": "Удалить группу правил «{name}»? Это невозможно отменить.", + "deleting": "Удаление...", + "confirmDelete": "Удалить", + "operationFailed": "Операция не удалась", + "autoConfigMustBeObject": "Автоматическая конфигурация должна быть объектом JSON.", + "autoConfigInvalid": "Неверный формат автоматической конфигурации.", + "saveFailed": "Сохранить не удалось", + "enabled": "Включено", + "disabled": "Отключено", + "enable": "Включено", + "disable": "Отключено", + "global": "Глобальный", + "custom": "Пользовательский", + "allSites": "Все сайты", + "siteCount": "Сайтов: {count}", + "columns": { + "name": "Название", + "type": "Тип", + "status": "Статус", + "nodeCount": "Узлы", + "scope": "Область применения", + "updatedAt": "Обновлено", + "actions": "Действия" + }, + "actions": { + "compose": "Изменить", + "delete": "Удалить" + }, + "createDialog": { + "title": "Новое правило WAF", + "description": "После создания вы откроете редактор и настроите процесс обработки.", + "name": "Название правила", + "namePlaceholder": "например Защита кромок", + "submit": "Создавать и редактировать" + }, + "editor": { + "missingId": "Требуется действительное правило ID.", + "loadFailed": "Не удалось загрузить правило. Пожалуйста, попробуйте еще раз.", + "reload": "Перезагрузить", + "back": "Назад", + "graphValid": "График действителен", + "issueCount": "Ошибок: {count}", + "unsaved": "несохраненный", + "enableRule": "Включить правило", + "format": "Формат", + "formatTitle": "Автоматическое расположение узлов и ребер", + "saving": "Сохранение...", + "save": "Сохранить", + "saved": "График правил сохранен.", + "conflict": "Это правило было обновлено в другом месте. Пожалуйста, перезагрузите.", + "saveCheckNodes": "Сохранить не удалось. Проверьте выделенные узлы и ребра.", + "ruleEnabled": "Правило включено", + "ruleDisabled": "Правило отключено", + "leaveTitle": "Несохраненные изменения", + "leaveDesc": "У вас есть несохраненные изменения. Все равно уйти?", + "leaveConfirm": "Уйти", + "deleteNode": "Удалить узел", + "systemNodeLocked": "Системные узлы не могут быть удалены", + "deleteEdge": "Удалить край", + "propsTitle": "Свойства узла", + "propsDesc": "Настроить этот процессор", + "systemNodeNoConfig": "Системные узлы не нуждаются в настройке.", + "displayName": "Отображаемое имя", + "ips": "IP адреса", + "cidrs": "CIDR диапазоны", + "ipGroups": "IP группы", + "countries": "Коды стран", + "countriesDesc": "Стран и территорий: {count}. Показаны названия и коды ISO.", + "regions": "Коды регионов", + "regionsDesc": "Регионов первого уровня: {count}. Поиск по названию или коду.", + "uaCheck": "Проверка User-Agent", + "enableUaCheck": "Включить флажок UA", + "enableUaCheckTip": "Если эта функция включена, запросы без User-Agent возвращают False.", + "block": "Заблокировать", + "blockTip": "Соответствие возвращает false и имеет приоритет над правилами сопоставления.", + "blockBots": "Блокировать UA обычных сканеров", + "blockBotsTip": "Браузер или OS классифицируется как бот (бот/паук/сканер/хлюпает, например Googlebot)", + "blockAbnormal": "Блокировать аномальные UA", + "blockAbnormalTip": "Браузер классифицирован как «Другой» или «Неизвестный» (исключая ботов поисковых систем; для сканеров используйте переключатель выше)", + "blockCustom": "Блокировать пользовательские UA", + "blockCustomTip": "Возвращает false, если необработанный User-Agent соответствует любому регулярному выражению (синтаксис шаблона Lua)", + "customUaPatterns": "Пользовательское регулярное выражение UA", + "customUaPatternsTip": "Одно регулярное выражение Lua-шаблона в каждой строке. Любое совпадение возвращает false. Макс 32.", + "uaMatch": "UA совпадение", + "matchMode": "Режим матча", + "matchModeTip": "Используется, когда установлены параметры браузера и OS.", + "matchOr": "OR", + "matchAnd": "AND", + "browsers": "Браузеры", + "os": "Операционные системы", + "security": "Проверки безопасности", + "securityTip": "Любое включенное совпадение правил возвращает False", + "basicProtection": "Базовая защита", + "help": "Помощь", + "oneValuePerLine": "Одно значение в строке", + "selectedCount": "Выбрано: {count}", + "pleaseSelect": "Выбрать", + "createLabel": "Новый {label}", + "searchNameOrCode": "Поиск имени или кода", + "addCode": "Добавить код", + "searchOptions": "Поиск по названию или коду ({count})", + "noMatchingOptions": "Нет подходящих вариантов", + "algorithm": "Алгоритм", + "algorithmFast": "Быстро", + "algorithmSlow": "Прочный", + "difficulty": "Сложность", + "sessionTtl": "Сеанс TTL (секунды)", + "challengeTtl": "Вызов TTL (секунды)", + "statusCode": "HTTP код состояния", + "responseBody": "HTML тело ответа", + "bytes": "байты", + "securityRules": { + "path_traversal": { + "label": "Обход пути", + "tip": "Обнаружение ../ и закодированных вариантов в пути/запросе/теле" + }, + "file_inclusion": { + "label": "Включение файла (LFI/RFI)", + "tip": "Обнаружение php://, file://, /etc/passwd и подобных в Path/Query/Body" + }, + "sql_injection": { + "label": "SQL инъекция", + "tip": "Обнаружение шаблонов внедрения SQL в запросе/теле/заголовке/файле cookie" + }, + "command_injection": { + "label": "Внедрение команд", + "tip": "Обнаружение шаблонов внедрения команд OS в запросе/теле/заголовке" + }, + "xss": { + "label": "XSS", + "tip": "Обнаружение отраженных шаблонов XSS в запросе/теле/заголовке" + }, + "ssrf": { + "label": "SSRF", + "tip": "Обнаружение частных адресов и опасных протоколов в запросе/теле" + }, + "malicious_upload": { + "label": "Вредоносная загрузка", + "tip": "Обнаружение опасных составных имен и расширений файлов" + }, + "xxe": { + "label": "XXE", + "tip": "Обнаружение шаблонов внешних объектов в телах XML" + }, + "crlf_injection": { + "label": "CRLF инъекция", + "tip": "Обнаружение инъекции новой строки в заголовке/запросе/теле" + } + } + } + }, + "ipGroups": { + "title": "IP группы", + "create": "Новая группа IP", + "listTitle": "IP группы", + "listDesc": "Поддерживать ручные, автоматические и подписные наборы IP, на которые WAF могут ссылаться списки разрешений/запретов.", + "refresh": "Обновить", + "loading": "Загрузка групп IP...", + "empty": "IP групп пока нет. Сначала создайте его.", + "updated": "Группа IP обновлена", + "created": "IP группа создана", + "deleted": "IP группа удалена", + "synced": "Синхронизация завершена", + "ipRemoved": "IP удалено", + "testHit": "Проверка завершена. Совпавших IP-адресов: {count}.", + "testMiss": "Проверка правил завершена. Ни один IP-адрес не соответствует.", + "deleteTitle": "Удалить группу IP", + "deleteDesc": "Удалить группу IP «{name}»? Это невозможно отменить.", + "deleting": "Удаление...", + "confirmDelete": "Удалить", + "operationFailed": "Операция не удалась", + "types": { + "manual": "Вручную", + "automatic": "Автоматический", + "subscription": "Подписка" + }, + "enabled": "Включено", + "disabled": "Отключено", + "noSyncRecord": "Нет истории синхронизации", + "columns": { + "name": "Название", + "type": "Тип", + "status": "Статус", + "ipCount": "IP-адреса", + "refCount": "Ссылки", + "syncStatus": "Статус синхронизации", + "updatedAt": "Обновлено", + "actions": "Действия" + }, + "actions": { + "view": "Посмотреть", + "edit": "Изменить", + "test": "Правило теста", + "runNow": "Беги сейчас", + "syncNow": "Синхронизировать сейчас", + "delete": "Удалить" + }, + "ban": { + "permanent": "Постоянный", + "expired": "Срок действия истек", + "minutesLater": "через {count} мин.", + "hoursLater": "через {count} ч" + }, + "presets": { + "scan404": "Высокочастотное сканирование 404 с одного IP", + "directAccess": "Аномальный прямой доступ с одного IP" + }, + "dialog": { + "editTitle": "Изменить {name}", + "createTitle": "Новая группа IP", + "description": "Поддерживать ручные, автоматические и подписные наборы IP, на которые WAF могут ссылаться списки разрешений/запретов.", + "name": "IP имя группы", + "nameRequired": "Введите имя группы IP.", + "type": "Тип", + "enable": "Включить группу IP", + "enableDesc": "Если этот параметр отключен, конфигурация сохраняется, но не расширяется в список времени выполнения WAF при публикации.", + "subscriptionUrl": "Подписка URL", + "subscriptionUrlRequired": "Тип подписки требует URL", + "subscriptionFormat": "Формат подписки", + "formatText": "Текстовый список", + "syncInterval": "Интервал синхронизации (минуты)", + "syncIntervalDesc": "Минимум 1 минута. По умолчанию 1440 минут.", + "mappingRule": "JSON правило сопоставления", + "mappingPlaceholder": "Оставьте пустым для корневого массива", + "autoIntervalDesc": "Как часто извлекать вредоносные IP-адреса из журналов запросов. Минимум 1 минута, по умолчанию 1440 минут.", + "presetRules": "Предустановленные правила", + "autoConfigJson": "Автоматическая конфигурация JSON", + "ipList": "IP-адреса/CIDR-адреса", + "ipListSubscriptionHint": "При следующей синхронизации подписки этот список будет перезаписан. Вы все равно можете сначала сохранить текущее содержимое.", + "ipListManualHint": "Один IP или CIDR в каждой строке.", + "saving": "Сохранение...", + "save": "Сохранить группу IP" + }, + "testDialog": { + "title": "Результат автоматической проверки правил", + "description": "Воспроизведение журналов запросов в соответствии с текущей автоматической конфигурацией JSON.", + "testing": "Тестирование...", + "summary": "{lookback} · правил: {rules} · совпавших IP-адресов: {matched}", + "testedAt": "Протестировано: {time}", + "noMatch": "Ни один IP-адрес не соответствует.", + "empty": "Результата теста пока нет.", + "close": "Закрыть" + }, + "viewDialog": { + "titleNamed": "Посмотреть {name}", + "title": "Посмотреть группу IP", + "summary": "{type} · IP-адресов: {count}", + "summaryFiltered": "{type} · IP-адресов: {count}, совпадений: {matched}", + "searchPlaceholder": "Поиск IP адресов...", + "clearSearch": "Очистить поиск", + "noSearchResult": "Соответствующие адреса IP не найдены.", + "fallbackDesc": "Просмотрите IP-адреса в этой группе и удалите записи, которые вам больше не нужны.", + "loading": "Загрузка списка IP...", + "noGroup": "Группа IP не выбрана.", + "emptyAuto": "Это автоматическое правило еще не зафиксировало ни одного IP-адреса. Нажмите «Запустить сейчас», чтобы запустить захват.", + "empty": "В этой группе IP нет IP-адресов.", + "subscriptionHint": "Группы подписки могут восстановить удаленные IP-адреса при следующей синхронизации.", + "ipAddress": "IP адрес", + "capturedAt": "Снято в", + "banRemaining": "Запретить осталось", + "actions": "Действия", + "deleteIp": "Удалить {ip}", + "close": "Закрыть", + "deleteTitle": "Удалить IP", + "deleteDesc": "Удалить {ip} из группы IP «{name}»?", + "deleting": "Удаление...", + "confirmDelete": "Удалить" + }, + "autoConfigMustBeObject": "Автоматическая конфигурация должна быть объектом JSON." + }, + "rateLimits": { + "title": "Лимиты запросов", + "subtitle": "Проверьте нагрузку на пограничные запросы и настройте ограничения параллелизма и пропускной способности по умолчанию.", + "loadingAuth": "Загрузка разрешений...", + "forbiddenTitle": "Недостаточно разрешений", + "forbiddenDesc": "Доступ к настройкам ограничения скорости имеют только администраторы.", + "tabAnalysis": "Анализ", + "tabConfig": "Конфигурация", + "loadingPage": "Загрузка ограничений скорости...", + "loadingPressure": "Загрузка данных о нагрузке…", + "loadFailed": "Загрузка не удалась", + "emptyTitle": "Нет данных", + "emptyDesc": "В текущем временном диапазоне нет журналов доступа.", + "topHost": "Самый высокий средний хост RPS", + "topIp": "Самый высокий средний показатель RPS IP", + "avgRate": "Средняя частота запросов для {range}", + "range": { + "h24": "24 часа", + "h72": "3 дня" + }, + "chart": { + "title": "Нагрузка от запросов", + "empty": "Нет данных о нагрузке", + "requestRate": "Запросить ставку", + "uniqueVisitors": "Уникальные посетители", + "visitorsPerBucket": "Посетители / сегмент" + }, + "config": { + "saved": "Настройки ограничения скорости сохранены.", + "saveFailed": "Сохранить не удалось", + "loading": "Загрузка настроек ограничения скорости...", + "preview": "Посмотреть предварительный просмотр конфигурации", + "title": "Глобальные лимиты запросов", + "description": "0 или пусто означает, что по умолчанию отключено. Сайты без собственных настроек наследуют эти значения. Опубликуйте релиз, чтобы применить изменения.", + "save": "Сохранить", + "connPerServer": "Параллелизм по умолчанию (на сайт)", + "connPerServerHint": "Максимальное количество одновременных подключений по умолчанию на сайт", + "connPerIp": "Параллелизм по умолчанию на IP", + "connPerIpHint": "Максимальное количество одновременных подключений по умолчанию на IP", + "limitRate": "Ограничение пропускной способности по умолчанию", + "limitRateHint": "Скорость передачи данных на один запрос по умолчанию, например 512k или 1m", + "reqPerIp": "Ограничение скорости запросов по умолчанию на IP", + "reqPerIpHint": "Частота запросов на один IP по умолчанию, например 10r/s или 100r/m. Оставьте пустым, чтобы отключить.", + "invalidConn": "Введите неотрицательное целое число для параллелизма или оставьте пустым, чтобы отключить", + "invalidRate": "Неверный формат скорости. Используйте 512k, 1m, число или оставьте пустым для отключения.", + "invalidReq": "Неверный формат частоты запросов. Используйте 10r/s или 100r/m либо оставьте пустым, чтобы отключить." + } + }, + "pages": { + "title": "Страницы", + "subtitle": "Размещайте статические сайты на периферии. Загрузите статический пакет и выберите Pages в качестве основного в правилах прокси.", + "refresh": "Обновить", + "create": "Новый проект", + "loadFailed": "Загрузка не удалась", + "emptyTitle": "Проектов Pages пока нет", + "emptyDesc": "Создайте проект, а затем загрузите статический пакет развертывания.", + "enabled": "Включено", + "disabled": "Отключено", + "spaFallback": "SPA запасной вариант", + "strict404": "Строгий 404", + "currentActive": "В настоящее время активен", + "none": "Нет", + "activatedAt": "Активировано: ", + "notActivated": "Не активировано", + "viewDetail": "Посмотреть детали", + "saveFailed": "Сохранить не удалось", + "updated": "Проект обновлен", + "created": "Проект создан", + "deleted": "Проект удален", + "deleteFailed": "Удаление не удалось", + "editTitle": "Изменить проект страниц", + "createTitle": "Проект «Новые страницы»", + "editorDesc": "Настройте статические параметры хостинга. После загрузки пакета выберите Pages в качестве основного в правилах прокси.", + "settingsDesc": "Настройте статические параметры хостинга. При сохранении синхронизируются сведения о проекте и ссылки на прокси.", + "reset": "Сброс", + "saving": "Сохранение...", + "saveChanges": "Сохранить изменения", + "createProject": "Создать проект", + "form": { + "name": "Название проекта", + "nameRequired": "Введите название проекта", + "slug": "Ссылка проекта", + "slugPlaceholder": "Оставьте пустым для автоматической генерации", + "entryFile": "Входной файл", + "entryRequired": "Введите файл записи", + "rootDir": "Корневой каталог", + "optional": "Необязательно", + "spaFallback": "SPA запасной вариант", + "spaFallbackDesc": "Вернитесь к указанному пути, если ни один статический файл не соответствует", + "fallbackPath": "Резервный путь", + "fallbackPathPrefix": "Резервный путь должен начинаться с /", + "apiProxy": "API обратный прокси", + "apiProxyDesc": "Прикрепите правила прокси API к статическому сайту.", + "matchPath": "Путь совпадения", + "matchPathPrefix": "Путь совпадения должен начинаться с /", + "backend": "Бэкэнд URL", + "backendPrefix": "Серверная часть URL должна начинаться с http:// или https://.", + "rewrite": "Переписать правило" + }, + "detail": { + "invalidId": "Требуется действительный проект Pages ID.", + "loading": "Загрузка сведений о проекте...", + "loadFailed": "Не удалось загрузить сведения о проекте.", + "back": "Вернуться к списку", + "notFound": "Этот проект Pages не существует или был удален.", + "deploymentCount": "Развёртываний: {count}", + "tabDeployments": "Развертывания", + "tabSettings": "Настройки" + }, + "danger": { + "title": "Опасная зона", + "deleteProject": "Удалить проект", + "deleteHint": "Удалите навсегда {name} ({slug}) и все его развертывания.", + "confirmTitle": "Удалить проект страниц", + "confirmDesc": "Удалить проект {name}? Это невозможно отменить.", + "confirm": "Удалить" + }, + "upload": { + "selectFile": "Выберите пакет развертывания", + "success": "Пакет развертывания загружен.", + "failed": "Загрузка не удалась", + "unsupported": "Поддерживаются только пакеты zip, tar.gz, tar.xz, tar.bz2, tar и 7z.", + "title": "Загрузить пакет развертывания", + "description": "Загрузите неизменяемый статический архив. Позже вы сможете активировать его из истории развертываний.", + "localPackage": "Локальный пакет", + "dropHint": "Перетащите пакет сюда или нажмите, чтобы выбрать файл", + "selected": "Выбрано {name} ({size})", + "chooseArchive": "Выберите поддерживаемый архив.", + "entry": "Запись о развертывании", + "entryHint": "Запись происходит из настроек проекта. Загрузка пакета не перезаписывает его.", + "processing": "Обработка на сервере", + "progress": "Ход загрузки", + "pleaseWait": "Пожалуйста, подождите", + "uploading": "Загрузка...", + "submit": "Загрузите и создайте развертывание" + }, + "files": { + "loadFailed": "Не удалось загрузить список файлов", + "empty": "Нет записей в файлах", + "path": "Путь", + "size": "Размер" + }, + "history": { + "source": { + "manual_upload": "Локальная загрузка", + "manual_url": "URL импорт", + "remote_url": "URL архива" + }, + "trigger": { + "manual_upload": "Загрузка вручную", + "manual_url": "Ручной импорт", + "manual_sync": "Ручная синхронизация", + "scheduled_auto_update": "Запланированное обновление" + }, + "fileMeta": "{checksum} · файлов: {count} · ", + "createdAt": "Создано {time}", + "activatedAt": " · Активировано {time}", + "collapseFiles": "Свернуть список файлов", + "expandFiles": "Развернуть список файлов", + "deploymentNumber": "Развертывание №{number}", + "activate": "Активировать", + "delete": "Удалить", + "activated": "Историческое развертывание активировано. Автообновление (если оно включено) отключено.", + "activateFailed": "Активация не удалась", + "deleted": "Развертывание удалено.", + "deleteFailed": "Удаление не удалось", + "activateTitle": "Активировать историческое развертывание", + "deleteTitle": "Удалить развертывание", + "activateDesc": "Активация другого исторического развертывания останавливает текущую исходную задачу. Если автоматическое обновление включено, оно будет отключено.", + "deleteDesc": "Удалить развертывание №{number}? Это невозможно отменить.", + "confirm": "Подтвердить", + "loading": "Загрузка истории развертываний...", + "loadFailed": "Не удалось загрузить историю развертываний.", + "productionHint": "Это живое производственное развертывание. Изменение активного развертывания вступает в силу немедленно.", + "manualUpload": "Загрузка вручную", + "emptyProductionTitle": "Нет производственного развертывания", + "emptyProductionDesc": "Загрузите локальный пакет или настройте удаленный источник выпуска URL/GitHub и синхронизируйте его.", + "historyHint": "Развертывания являются неизменяемыми. Исходная информация — это снимок времени создания. Вы можете активировать или удалить исторические развертывания.", + "emptyTitle": "Нет развертываний", + "emptyDesc": "Загрузите локальный пакет или настройте удаленный источник выпуска URL/GitHub и синхронизируйте его." + }, + "source": { + "status": { + "idle": "Простой", + "checking": "Проверка", + "update_available": "Доступно обновление", + "syncing": "Синхронизация", + "failed": "Последняя синхронизация не удалась", + "attention": "Требуется подтверждение" + }, + "check": "Проверить", + "syncPublish": "Синхронизировать и опубликовать", + "missingExecutionId": "Не выполнено задание ID", + "actionDone": "Источник {action} завершен.", + "actionFailed": "Источник {action} не удался", + "checkQueued": "Проверить задачу в очереди", + "checkQueueFailed": "Не удалось поставить задачу проверки в очередь", + "syncQueued": "Задача синхронизации поставлена в очередь", + "syncQueueFailed": "Не удалось поставить задачу синхронизации в очередь.", + "title": "Источник развертывания", + "loadingDesc": "Загрузка исходной конфигурации...", + "independentDesc": "Исходная конфигурация не зависит от истории развертывания.", + "loadFailed": "Не удалось загрузить источник развертывания.", + "configDesc": "Настройте удаленный источник и синхронизируйте его для публикации. Результаты отображаются в истории развертываний.", + "manualBadge": "Развертывание вручную", + "localPackage": "Локальный пакет", + "noRemote": "Нет постоянного удаленного источника. Загрузите пакет, а затем активируйте его из истории развертываний.", + "submitFailed": "Не удалось поставить исходную задачу в очередь.", + "statusFailed": "Не удалось прочитать статус задачи.", + "waitStopped": "Автоматическое ожидание остановлено. Задача может все еще выполняться в фоновом режиме.", + "refreshStatus": "Обновить статус задачи", + "configure": "Настроить", + "checkUpdate": "Проверьте наличие обновлений", + "refresh": "Обновить", + "confirmTitle": "Опубликовать текущую версию GitHub", + "confirmLead": "При этом публикуется точная ревизия, показанная в данный момент на карте:", + "revisionUnavailable": "Текущая редакция больше не доступна. Обновите и повторите попытку.", + "confirmPublish": "Подтвердить и опубликовать", + "updated": "Источник развертывания обновлен.", + "updateFailed": "Не удалось обновить источник развертывания.", + "switchedManual": "Переключен обратно на ручное развертывание", + "switchFailed": "Переключиться не удалось", + "urlRequired": "Введите удаленный URL", + "urlInvalid": "Введите действительный HTTP(S) URL", + "githubUrlInvalid": "Введите общедоступный репозиторий URL в формате https://github.com/'{owner}'/'{repo}'.", + "tagInvalid": "Тег выпуска должен быть допустимой ссылкой Git (допускаются 1–255 байт; / # & =).", + "assetInvalid": "Имя ресурса должно иметь длину от 1 до 255 байт и не может быть путем или содержать символы управления, новой строки или двунаправленного текста.", + "intervalInvalid": "Интервал проверки должен быть целым числом от 5 до 1440 минут.", + "useManual": "Использовать развертывание вручную", + "saveRemote": "Сохранить удаленный источник", + "saveGithub": "Сохранить источник GitHub", + "settingsTitle": "Источник развертывания", + "settingsDesc": "Выберите источник развертывания.", + "mode": "Тип источника", + "manual": "Развертывание вручную", + "manualHint": "Сохраните существующие развертывания и текущую производственную версию. Создайте новые развертывания с помощью «Загрузить пакет».", + "urlHint": "Введите HTTP(S) URL, чтобы загрузить пакет развертывания напрямую.", + "allowInsecure": "Разрешить небезопасные соединения", + "allowInsecureDesc": "По умолчанию разрешены публичные и частные адреса. Включение этого параметра позволяет пропустить проверку сертификата TLS для самозаверяющих или частных центров сертификации.", + "switchManualTitle": "Вернитесь к развертыванию вручную", + "switchManualDesc": "Текущая исходная конфигурация будет удалена. Существующие развертывания и текущая производственная версия сохраняются.", + "githubUrl": "GitHub хранилище URL", + "githubUrlHint": "Поддерживаются только общедоступные репозитории github.com.", + "releaseSelect": "Выбор релиза", + "latestRelease": "Последний выпуск", + "pinnedTag": "Закрепленный тег", + "pinnedTagHint": "Проверьте и синхронизируйте именно этот тег.", + "autoUpdate": "Автообновление", + "autoUpdateHint": "Автоматически синхронизировать и публиковать при обнаружении нового выпуска.", + "checkInterval": "Интервал проверки (минуты)", + "checkIntervalHint": "Можно установить от 5 до 1440 минут.", + "assetName": "Имя файла в релизе", + "noRecord": "Пока нет записей", + "address": "URL", + "verifyCert": "Проверить сертификат", + "lastSync": "Последняя синхронизация", + "neverSynced": "Еще не синхронизировано", + "applied": "Прикладной", + "latestReleaseLabel": "Последний выпуск", + "pinnedTagLabel": "Закрепленный тег · {tag}", + "tagMissing": "Не предусмотрено", + "assetChangedTitle": "Релизный актив изменен и требует подтверждения", + "assetChangedDesc": "Удаленная редакция не соответствует опубликованному содержимому. Проверьте версию и ресурсы, а затем подтвердите публикацию именно этой версии.", + "autoUpdateOn": "Вкл.", + "autoUpdateOff": "Выкл.", + "intervalMinutes": "{count, plural, one {# минута} few {# минуты} many {# минут} other {# минуты}}", + "nextCheck": "Следующая проверка", + "waitingSchedule": "Жду расписания", + "lastCheck": "Последняя проверка", + "neverChecked": "Еще не проверено", + "remote": "Удаленный" + } + }, + "accessLogs": { + "title": "Журнал запросов", + "subtitle": "Просмотрите обзор трафика, детали IP и запросите журналы.", + "refresh": "Обновить", + "cleanup": "Очистка журналов", + "tabOverview": "Обзор", + "tabIps": "IP-адреса", + "tabList": "Журналы", + "cleaned": "Удалено записей журнала: {count}", + "cleanupFailed": "Очистка не удалась", + "loadingTitle": "Загрузка журналов доступа", + "loadingDesc": "Готовим страницу...", + "loadFailed": "Загрузка не удалась", + "range": { + "h24": "24 часа", + "h168": "7 дней", + "h360": "15 дней", + "h720": "30 дней" + }, + "rangeHint": { + "hours24": "Последние 24 часа", + "days": "Период: {count} дн.", + "hours": "Период: {count} ч" + }, + "sort": { + "logged_at:desc": "Сначала самые новые", + "logged_at:asc": "Сначала самый старый", + "status_code:desc": "Статус от высокого к низкому", + "status_code:asc": "Статус от низкого к высокому", + "remote_addr:asc": "IP А – Я", + "remote_addr:desc": "IP Я – А", + "total_requests:desc": "Запросы от высокого к низкому", + "total_requests:asc": "Запросы от низкого к высокому", + "request_length:desc": "Входящий от высокого к низкому", + "request_length:asc": "Входящий от низкого к высокому", + "bytes_sent:desc": "Исходящий от высокого к низкому", + "bytes_sent:asc": "Исходящий от низкого к высокому", + "success_ratio:desc": "Соотношение 2xx от высокого к низкому", + "success_ratio:asc": "Соотношение 2xx от низкого к высокому", + "last_seen_at:desc": "Последний раз видели сначала новые", + "last_seen_at:asc": "Последний раз видели самого старшего первым" + }, + "cache": { + "hit": "Хит", + "origin": "Сервер-источник", + "uncached": "Некэшированный" + }, + "filters": { + "pickTime": "Выберите время", + "remoteAddr": "Источник IP", + "remoteAddrPlaceholder": "Поиск по IP", + "host": "Хост", + "hostPlaceholder": "Поиск по хосту", + "statusCode": "Код состояния", + "statusCodePlaceholder": "Введите код состояния, например. 404", + "more": "Больше фильтров", + "nodeId": "Узел ID", + "nodeIdPlaceholder": "Поиск по node_id", + "path": "Путь", + "pathPlaceholder": "Поиск по пути", + "timeRange": "Временной диапазон", + "pageSize": "За страницу", + "pageSizeItem": "По {count} на странице", + "apply": "Фильтр", + "clear": "Очистить" + }, + "cleanupDialog": { + "invalidDays": "Дней хранения должно быть больше 0.", + "title": "Очистка журналов доступа", + "description": "Удалите журналы доступа старше срока хранения. Это невозможно отменить.", + "policy": "Политика хранения", + "keepDays": "Срок хранения: {days} дн.", + "custom": "Пользовательские дни", + "customDays": "Пользовательские дни хранения", + "cleaning": "Очистка...", + "confirm": "Очистить" + }, + "detail": { + "title": "Доступ к данным журнала", + "description": "Все поля для этого запроса. Используйте вкладку IP-адреса для анализа IP.", + "logId": "Запись ID", + "requestTime": "Запросить время", + "storedAt": "Хранится в", + "node": "Узел", + "nodeId": "Узел ID", + "clientIp": "Клиент IP", + "region": "Регион", + "host": "Хост", + "status": "Статус", + "cache": "Кэш", + "bytesSent": "Исходящий", + "requestLength": "Входящий", + "duration": "Продолжительность", + "path": "Путь", + "page": "Страница {page}", + "prev": "Назад", + "next": "Далее", + "listTitle": "Журналы", + "empty": "Нет журналов доступа", + "time": "Время", + "view": "Посмотреть детали" + }, + "ip": { + "needRange": "Введите время начала и окончания", + "invalidTime": "Неверный формат времени", + "endAfterStart": "Время окончания должно быть позже времени начала.", + "maxRange": "Диапазон времени не может превышать 30 дней.", + "page": "Страница {page}", + "total": " · IP-адресов: {count}", + "prev": "Назад", + "next": "Далее", + "customRange": "Пользовательский диапазон", + "customPending": "Пользовательский (не применяется)", + "timeRange": "Временной диапазон", + "current": "Текущий: {range}", + "custom": "Пользовательский", + "start": "Запустить", + "end": "Конец", + "apply": "Применить", + "applyHint": "Нажмите «Применить» после изменения времени. Окно подробностей соответствует диапазону списка.", + "sort": "Сортировать", + "pageSize": "За страницу", + "title": "IP-адреса", + "empty": "Нет данных IP", + "region": "Регион", + "requests": "Запросы", + "successRatio": "соотношение 2хх", + "inbound": "Входящий", + "outbound": "Исходящий", + "lastSeen": "Последний раз видели", + "view": "Посмотреть детали IP", + "dialogTitle": "IP подробности", + "dialogDesc": ". Просмотрите тенденцию трафика этого IP, его распределение и групповые действия WAF IP-." + }, + "analysis": { + "emptySeries": "Нет данных {title}", + "selectGroup": "Выберите группу IP, чтобы присоединиться.", + "added": "Добавлен {ip} в группу IP «{name}».", + "addFailed": "Не удалось добавить в группу IP.", + "removed": "Удален {ip} из группы IP «{name}».", + "removeFailed": "Удалить не удалось", + "joinTitle": "Добавьте IP в группу IP.", + "targetIp": "Цель IP:", + "loadingGroups": "Загрузка групп IP", + "loadGroupsFailed": "Не удалось загрузить группы IP.", + "alreadyIn": "Этот IP уже есть в этих IP группах.", + "alreadyInHint": "Вы можете удалить его или добавить в другую группу IP.", + "entryCount": "{type} · записей: {count}", + "delete": "Удалить", + "cannotDelete": "Невозможно удалить вручную", + "notInAny": "Этого IP пока нет ни в одной группе IP.", + "addToOther": "Добавить в другую группу IP", + "noWritable": "Нет записываемых вручную групп IP (или они уже содержат это IP).", + "selectManual": "Выберите группу вручную IP.", + "close": "Закрыть", + "processing": "Работаю...", + "joinSelected": "Добавить в выбранную группу IP", + "recentHours": "Период: {hours} ч", + "invalidIp": "Нет допустимого IP.", + "addToGroup": "Добавьте IP в группу IP.", + "loading": "Загрузка анализа IP", + "loadFailed": "Не удалось загрузить анализ IP.", + "totalRequests": "Всего запросов", + "errors": "Ошибки", + "bandwidth": "Обслуживаемая пропускная способность", + "received": "Полученные данные", + "uniqueHosts": "Уникальные хосты", + "uniquePaths": "Уникальные пути", + "trend": "IP тренд запроса", + "trendMeta": "{ip} · {range} · интервал: {minutes} мин", + "refresh": "Обновить", + "trendFailed": "Не удалось загрузить тренд.", + "trendEmpty": "У этого IP нет запросов в {range}." + }, + "overview": { + "loadingTitle": "Загрузка обзора трафика", + "loadingDesc": "Агрегирование запросов, посещений и тенденций пропускной способности...", + "loadFailed": "Не удалось загрузить обзор трафика.", + "emptyTitle": "Нет обзорных данных", + "emptyDesc": "В текущем временном диапазоне нет статистики трафика.", + "uniqueVisitors": "{hint} · уникальные посетители", + "served": "{hint} · данные отправлены", + "requestsHint": "Следите за необычными скачками или падениями объема запросов.", + "requests": "Запросы", + "deviceDesc": "Запросите общий доступ по типу устройства.", + "deviceEmpty": "Нет данных о типе устройства", + "statusDesc": "Запросите долю по коду статуса HTTP.", + "statusEmpty": "Нет данных кода состояния", + "topPathsDesc": "Наиболее востребованные пути.", + "topHostsDesc": "Хосты с наибольшим трафиком.", + "topIpsDesc": "Исходные IP-адреса с наибольшим количеством запросов.", + "browsersDesc": "Запросы, агрегированные по браузеру.", + "osDesc": "Запросы, агрегированные по операционной системе.", + "uaDesc": "Запросы ранжированы по необработанному значению User-Agent." + }, + "toolbar": { + "hostsFiltered": "Хостов в фильтре: {count}", + "filterByHost": "Фильтровать по хосту", + "filterHosts": "Фильтровать хосты", + "clear": "Очистить", + "searchPlaceholder": "Зона поиска или хост", + "loadingHosts": "Загрузка хостов…", + "loadHostsFailed": "Не удалось загрузить хосты", + "retry": "Повторить", + "noRegistered": "Нет зарегистрированных хостов", + "noMatch": "Нет подходящих хостов", + "selectZone": "Выберите зону {name}", + "selectHost": "Выберите хост {name}", + "selectedCount": "Выбрано хостов: {count}" + } + }, + "cloudflare": { + "title": "Cloudflare", + "connectionSettings": "Настройки подключения", + "addGroup": "Новая группа", + "loadingOverview": "Загружается обзор Cloudflare...", + "notReadyTitle": "Cloudflare соединение не готово", + "notReadyDesc": "Импортируйте существующую учетную запись Cloudflare DNS или введите отдельный токен API и проверьте соединение.", + "configure": "Настроить соединение", + "groupsTitle": "Группы указателей", + "groupsDesc": "Управляйте целями Cloudflare DNS и членами домена.", + "loadingGroups": "Загрузка групп указателей...", + "emptyTitle": "Нет указывающих групп", + "emptyDesc": "Создайте группу, чтобы привязать членов домена к граничному узлу или пользовательской цели DNS.", + "enabled": "Включено", + "disabled": "Отключено", + "memberSummary": "Доменов: {count} · проксирование новых доменов: {proxy}", + "proxyOn": "на", + "proxyOff": "выкл.", + "manage": "Управление", + "sync": "Синхронизировать", + "delete": "Удалить", + "created": "Группа указателей создана.", + "syncQueued": "Групповая синхронизация поставлена в очередь", + "deleted": "Группа указателей и удаленные записи удалены.", + "deleteTitle": "Удалить группу указателей", + "deleteDesc": "При этом будут удалены все члены записи {name} и удаленные записи DNS, управляемые этим модулем. Это невозможно отменить.", + "confirmDelete": "Удалить", + "loadingDetail": "Загрузка сведений о группе...", + "groupUpdated": "Настройки группы обновлены", + "memberAddedOne": "Домен добавлен и поставлен в очередь на синхронизацию", + "memberAddedMany": "Домены добавлены в очередь синхронизации: {count}", + "memberAddFailed": "Не удалось добавить домены: {count}", + "memberPartial": "Частичный успех: {succeeded} добавлено, {failed} не удалось.", + "proxyUpdated": "Настройки проксирования обновлены. Синхронизация поставлена в очередь.", + "memberSyncQueued": "Синхронизация участников поставлена в очередь", + "memberDeleted": "Запись участника и удаленная запись DNS удалены.", + "batchEnableProxy": "Включить проксирование", + "batchProxyEnabled": "Проксирование включено, синхронизация поставлена в очередь. Доменов: {count}.", + "back": "Вернуться к списку", + "refresh": "Обновить", + "edit": "Изменить", + "addDomain": "Добавить домен", + "currentPoint": "Текущая цель", + "activeNode": "Активный узел {name} · {ip}", + "customTargetSummary": "Пользовательская цель {type} · {content}", + "nodeTargetMode": "Привязка узла", + "customTargetMode": "Пользовательская запись", + "syncEnabled": "Синхронизация включена", + "syncDisabled": "Синхронизация отключена", + "primaryNode": "Первичный {name}", + "backupNode": "Резервная копия {name}", + "backupUnset": "Не установлено", + "members": "Участники домена", + "membersDesc": "Оранжевое облако для каждого участника является единственным источником достоверной информации во время синхронизации.", + "noMembers": "Пока нет участников домена.", + "columns": { + "domain": "Домен", + "desiredIp": "Желаемое значение", + "status": "Статус", + "proxied": "Оранжевое облако", + "actions": "Действия" + }, + "pendingSync": "Ожидает синхронизации", + "remove": "Удалить", + "groupDialog": { + "editTitle": "Редактировать группу указателей", + "createTitle": "Новая группа указателей", + "description": "Привяжите элементы к граничному узлу или назначьте настраиваемую цель записи DNS.", + "name": "Название группы", + "targetMode": "Целевой режим", + "bindNode": "Привязать узел", + "customTarget": "Пользовательский", + "recordType": "Тип записи", + "recordContent": "Значение DNS-записи", + "recordContentHint": "Введите IPv4 для A, IPv6 для AAAA или целевое имя хоста для CNAME.", + "recordContentPlaceholder": { + "CNAME": "например origin.example.com", + "A": "e.g. 203.0.113.10", + "AAAA": "например 2001:db8::10" + }, + "primary": "Первичный узел", + "primaryPlaceholder": "Выберите пограничный узел с IPv4.", + "noIp": "Нет IP настроено", + "backup": "Резервный узел", + "none": "Нет", + "defaultProxied": "Проксировать новые домены по умолчанию", + "enableSync": "Включить синхронизацию", + "save": "Сохранить" + }, + "memberDialog": { + "title": "Добавить участников домена", + "description": "Ищите и выбирайте оптом. Присоединение создает или перенимает уникальную одноименную запись DNS, используемую этой группой.", + "zoneDomain": "Домен зоны", + "searchPlaceholder": "Поиск доменов или зон…", + "selected": "Выбрано: {count}", + "visible": "Показано: {count}", + "unselectAll": "Снять выбор с видимых доменов", + "selectVisible": "Выбрать видимый", + "clear": "Очистить", + "noAvailable": "Нет доступных доменов (уже в группе или не добавлены в управление сайтом).", + "noMatch": "Нет подходящих доменов", + "groupByZone": "Группировать по зонам", + "selectZone": "Выберите зону {name}", + "zone": "Зона DNS", + "proxied": "Проксировать через Cloudflare", + "submitCount": "Добавить и синхронизировать ({count})", + "submit": "Добавить и синхронизировать" + }, + "tasks": { + "pending": "Ожидает", + "running": "Выполняется", + "succeeded": "Успешно", + "failed": "Ошибка", + "system": "Система", + "manual": "Вручную", + "retry": "Повторить", + "schedule": "Запланировано", + "retried": "Задача синхронизации повторно поставлена в очередь", + "newTaskId": "Новое задание ID: {id}", + "retryFailed": "Не удалось повторить задачу", + "unknownError": "Неизвестная ошибка", + "title": "Синхронизировать задачи", + "description": "Показывает только выполнение синхронизации домена Cloudflare (sync_member) и групповой синхронизации (sync_group).", + "statusPlaceholder": "Статус", + "allStatus": "Все статусы", + "refresh": "Обновить", + "loadFailed": "Не удалось загрузить задачи синхронизации.", + "loading": "Загрузка задач синхронизации...", + "emptyTitle": "Нет задач синхронизации", + "emptyDesc": "После добавления доменов или синхронизации группы здесь отображаются операции синхронизации домена/группы Cloudflare.", + "columns": { + "task": "Задача", + "status": "Статус", + "trigger": "Триггер", + "duration": "Продолжительность", + "result": "Результат/ошибка", + "createdAt": "Создано" + }, + "pageSummary": "Всего: {total}, страница {page}/{pages}", + "prev": "Назад", + "next": "Далее", + "detailTitle": "Синхронизировать сведения о задаче", + "defaultName": "Cloudflare синхронизация", + "loadingDetail": "Загрузка сведений о задаче...", + "status": "Статус", + "trigger": "Триггер", + "retries": "Повторные попытки", + "duration": "Продолжительность", + "taskId": "Задача ID", + "createdAt": "Создано", + "finishedAt": "Завершено", + "result": "Результат", + "error": "Ошибка", + "logs": "Журналы", + "close": "Закрыть" + }, + "settings": { + "saved": "Cloudflare соединение сохранено", + "verified": "Cloudflare соединение проверено", + "cleared": "Cloudflare соединение очищено", + "back": "Вернуться к списку", + "title": "Cloudflare соединение", + "sourceTitle": "Источник учетных данных", + "sourceDesc": "Токен должен иметь права Zone:Read и DNS:Edit. Значения токенов не отображаются в ответах API и интерфейсе.", + "source": "Источник подключения", + "importDns": "Импортировать существующую учетную запись DNS.", + "standalone": "Автономный токен API", + "dnsAccount": "Cloudflare DNS аккаунт", + "selectAccount": "Выберите учетную запись DNS.", + "dnsHint": "Показаны только учетные записи типа Cloudflare DNS. Добавьте один в", + "dnsLink": "DNS аккаунтов", + "dnsHintAfter": ".", + "tokenKeep": "Оставьте пустым, чтобы сохранить существующий токен; сохранение заменяет его", + "tokenEnter": "Введите токен Cloudflare API", + "save": "Сохранить настройки", + "test": "Тестовое соединение", + "clear": "Сбросить настройки подключения", + "statusTitle": "Текущий статус", + "ready": "Токен проверен. Cloudflare синхронизация указателей может быть запущена.", + "notReady": "После сохранения вам еще нужно протестировать соединение. Синхронизация отклоняется до тех пор, пока она не будет проверена." + } + }, + "openflareOps": { + "saved": "OpenFlare настройки операций сохранены", + "saveFailed": "Сохранить не удалось", + "tokenRotated": "Токен обнаружения заменён", + "tokenRotateFailed": "Не удалось заменить токен", + "kumaSynced": "Uptime Kuma синхронизация выполнена", + "syncFailed": "Синхронизация не удалась", + "invalidParams": "Проверка не удалась", + "loading": "Загрузка настроек операций OpenFlare...", + "loadFailed": "Загрузка не удалась", + "save": "Сохранить", + "agent": { + "title": "Работа агента", + "description": "Интервал контрольных сигналов и тайм-аут отсутствия связи передаются узлам при следующем контрольном сигнале.", + "heartbeat": "Интервал контрольных сигналов ({duration})", + "offline": "Тайм-аут отсутствия связи ({duration})", + "wsUpgrade": "Включить переход на WebSocket", + "wsUpgradeDesc": "После успешного контрольного сигнала по HTTP переходить на WebSocket, чтобы узлы сразу получали уведомления о публикации конфигурации.", + "updateRepo": "Репозиторий обновлений агента" + }, + "geo": { + "title": "IP геолокация", + "description": "Управляет источником GeoIP для карт узлов и подобных представлений. Геолокация журнала доступа посетителей всегда использует MaxMind mmdb.", + "mode": "Метод поиска", + "disabled": "Выкл.", + "testIp": "Тест IP", + "querying": "Поиск…", + "query": "Найти", + "country": "Страна/регион" + }, + "pages": { + "title": "Статический хостинг страниц", + "description": "Настройте ограничение размера загрузки и количество исторических развертываний, которые сохраняет каждый проект.", + "maxSize": "Ограничение размера пакета (МиБ)", + "history": "Хранить исторические развертывания (0 = неограниченно)", + "historyHint": "В каждом проекте хранится не более N развертываний: всегда сохраняется активное, затем остальные заполняются сначала самыми новыми. Дополнительные неактивные развертывания удаляются после успешной загрузки. Поддерживаются zip, tar.gz, tar.xz, tar.bz2, tar и 7z." + }, + "discovery": { + "title": "Токен обнаружения и развертывание", + "description": "Новые узлы используют токен обнаружения при первом подключении. Заменить токен можно в разделе управления узлами или кнопкой ниже.", + "manageNodes": "Управление узлами", + "rotate": "Заменить токен", + "tokenReadonly": "Токен обнаружения (только для чтения)", + "loading": "Загрузка…", + "notGenerated": "Не создано", + "command": "Однострочная команда развертывания", + "commandHint": "Укажите доступный URL сервера и получите токен.", + "copied": "Команда скопирована", + "copy": "Копировать команду" + }, + "kuma": { + "title": "Uptime Kuma интеграция", + "description": "Синхронизируйте сайты обратного прокси-сервера с экземпляром Uptime Kuma.", + "syncNow": "Синхронизировать сейчас", + "enable": "Включить Uptime Kuma", + "url": "Uptime Kuma URL", + "username": "Имя пользователя", + "password": "Пароль", + "passwordKeep": "Оставьте пустым, чтобы сохранить текущий пароль.", + "syncInterval": "Интервал синхронизации (минуты)", + "scope": "Область мониторинга", + "allSites": "Все сайты", + "selectedSites": "Выбранные сайты", + "selectedLabel": "Выбранные сайты", + "pickSites": "Выбирайте отслеживаемые сайты", + "noneSelected": "Сайты не выбраны", + "interval": "Интервал проверки (секунды)", + "retry": "Повторные попытки", + "retryInterval": "Интервал повтора (секунды)", + "timeout": "Тайм-аут запроса (секунды)" + }, + "duration": { + "minutes": "{count} мин", + "seconds": "{count} сек." + }, + "errors": { + "heartbeatMin": "Интервал контрольных сигналов не может быть меньше 1000 миллисекунд.", + "offlineMin": "Тайм-аут отсутствия связи не может быть меньше 10 000 миллисекунд.", + "offlineRatio": "Тайм-аут отсутствия связи должен быть как минимум в 3 раза больше интервала контрольных сигналов.", + "kumaUrl": "Введите Uptime Kuma URL.", + "kumaUser": "Введите имя пользователя Uptime Kuma.", + "syncInterval": "Интервал синхронизации должен быть положительным целым числом.", + "interval": "Интервал контрольных сигналов должен быть положительным целым числом.", + "retry": "Число повторов должно быть неотрицательным целым числом.", + "retryInterval": "Интервал повторных проверок должен быть положительным целым числом.", + "timeout": "Тайм-аут запроса должен быть положительным целым числом.", + "pagesSize": "Максимальный размер пакета страниц должен находиться в диапазоне от 1 до 2048 МБ.", + "pagesHistory": "Число страниц истории должно быть целым числом ≥ 0 (0 означает неограниченное количество)." + }, + "status": { + "title": "Статус системы", + "loadFailed": "Не удалось загрузить статус системы.", + "unknownError": "Произошла неизвестная ошибка", + "autoRefresh": "Автообновление", + "refresh": "Обновить", + "overview": "Состояние сервиса", + "overviewDesc": "Основные показатели работоспособности сервиса", + "uptime": "Время работы", + "goroutines": "Горутины", + "heapObjects": "Объекты в куче", + "memory": "Память", + "memoryDesc": "Использование основной памяти", + "alloc": "Текущий объём выделенной памяти", + "totalAlloc": "Всего выделено", + "sys": "Системная память", + "nextGc": "Порог следующего GC", + "usageRatio": "Текущее использование (Alloc/Sys)", + "heapStack": "Куча/стек", + "heapStackDesc": "Подробности кучи и стека во время выполнения", + "heapAlloc": "Выделено в куче", + "heapSys": "Память кучи, полученная от ОС", + "heapIdle": "Незанятая память кучи", + "heapInuse": "Занятая память кучи", + "heapReleased": "Память кучи, возвращённая ОС", + "stackInuse": "Занятая память стека", + "stackSys": "Память стека, полученная от ОС", + "structs": "Внутренние структуры", + "structsDesc": "Память внутренних структур среды выполнения", + "mspanInuse": "MSpan используется", + "mspanSys": "Память MSpan, полученная от ОС", + "mcacheInuse": "MCache используется", + "mcacheSys": "Память MCache, полученная от ОС", + "buckHashSys": "Профилирование хеш-таблицы", + "gcSys": "GC метаданные", + "otherSys": "Другая системная память", + "gc": "GC и выделение памяти", + "gcDesc": "История GC и счётчики операций с памятью", + "numGc": "Количество запусков GC", + "lastGc": "Время с момента последнего GC", + "pauseTotal": "Общая длительность пауз GC", + "lastPause": "Последняя пауза GC", + "mallocs": "Количество выделений памяти", + "frees": "Количество освобождений памяти", + "lookups": "Поиск по указателю" + }, + "siteModal": { + "title": "Выбирайте отслеживаемые сайты", + "description": "Выберите сайты обратного прокси-сервера для синхронизации с Uptime Kuma. Поиск по названию сайта или домену.", + "search": "Поиск сайтов", + "searchPlaceholder": "Поиск по имени или домену...", + "selectFiltered": "Выбрать отфильтрованное", + "clearFiltered": "Снять выбор с отфильтрованных сайтов", + "loading": "Загрузка сайтов...", + "loadFailed": "Не удалось загрузить сайты", + "noMatch": "Нет подходящих сайтов", + "select": "Выбрать", + "siteName": "Название сайта", + "primaryDomain": "Основной домен", + "selectedCount": "Выбрано сайтов для мониторинга: {count}", + "save": "Сохранить выбор" + } + } +} diff --git a/frontend/messages/fragments/security.zh-CN.json b/frontend/messages/fragments/security.zh-CN.json index 4dace12b..5435c9c2 100644 --- a/frontend/messages/fragments/security.zh-CN.json +++ b/frontend/messages/fragments/security.zh-CN.json @@ -425,7 +425,8 @@ "history": { "source": { "manual_upload": "本地上传", - "manual_url": "URL 导入" + "manual_url": "URL 导入", + "remote_url": "远程 URL" }, "trigger": { "manual_upload": "手动上传", diff --git a/frontend/messages/fragments/websites.en.json b/frontend/messages/fragments/websites.en.json index 03cb1d17..8936ee83 100644 --- a/frontend/messages/fragments/websites.en.json +++ b/frontend/messages/fragments/websites.en.json @@ -203,7 +203,7 @@ "certStatus": { "unknownExpiry": "Expiry unknown", "expired": "Expired", - "expiresInDays": "Expires within {days} days", + "expiresInDays": "Expires within {days, plural, one {# day} other {# days}}", "valid": "Valid" }, "validation": { diff --git a/frontend/messages/fragments/websites.ru.json b/frontend/messages/fragments/websites.ru.json new file mode 100644 index 00000000..52908a3f --- /dev/null +++ b/frontend/messages/fragments/websites.ru.json @@ -0,0 +1,584 @@ +{ + "websites": { + "title": "Сайты", + "createZone": "Добавить зону", + "searchRoot": "Корневой домен зоны поиска", + "loadingList": "Загрузка списка зон...", + "requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.", + "emptySearch": "Соответствующая зона не найдена.", + "emptyList": "Зон пока нет. Нажмите «Добавить зону» в правом верхнем углу, чтобы начать.", + "rootDomain": "Корневой домен", + "domainCount": "Домены", + "createdAt": "Создано", + "updatedAt": "Обновлено", + "actions": "Действия", + "manage": "Управление", + "manageZone": "Управление зоной", + "loadingDetail": "Загрузка сведений о зоне...", + "invalidId": "Неверный сайт ID. Откройте страницу сведений из списка веб-сайтов.", + "notFound": "Сайт не найден. Возможно, он был удален или ID недействителен.", + "back": "Назад", + "detailSubtitle": "Управление сайтом зоны", + "tabOverview": "Обзор", + "tabDomains": "Домены ({count})", + "tabCertificates": "Сертификаты ({count})", + "basicInfo": "Основная информация", + "basicInfoDesc": "Поддерживать регистрируемый корневой домен Зоны. После смены корня подтвердите, что его домены по-прежнему принадлежат ему.", + "currentRoot": "Текущий корневой домен", + "editRoot": "Изменить корневой домен", + "dangerZone": "Опасные действия", + "dangerZoneDesc": "Очистите все домены в зоне перед ее удалением. Удаление невозможно отменить.", + "deleteZone": "Удалить зону", + "deleteZoneTitle": "Удалить зону", + "deleteZoneDesc": "Удалить {domain}? Домены под ним должны быть сначала отвязаны от маршрутов. Это невозможно отменить.", + "deleting": "Удаление…", + "confirmDelete": "Удалить", + "zoneDeleted": "Зона удалена", + "editZone": "Редактировать зону", + "editorDesc": "Зона принимает только регистрируемый корневой домен, например example.com.", + "rootRequired": "Введите корневой домен зоны", + "rootInvalid": "Введите корневой домен без протокола или подстановочного знака.", + "zoneUpdated": "Зона обновлена", + "zoneCreated": "Зона создана", + "saveFailed": "Сохранить не удалось", + "saveChanges": "Сохранить изменения", + "range24h": "24 часа", + "range7d": "7 дней", + "range30d": "30 дней", + "chartUv": "Ведро UV", + "chartBytes": "Данные переданы", + "statsFailed": "Не удалось загрузить статистику трафика.", + "analyticsUnavailable": "Хранилище аналитики недоступно, поэтому диаграммы могут быть пустыми. Убедитесь, что ClickHouse включен.", + "uniqueVisitors": "Уникальные посетители в окне запроса", + "uniqueVisitorsDesc": "Верхняя сумма дедуплицируется по всему окну; кривая равна UV для каждого сегмента времени, и сегменты не могут быть сложены вместе", + "totalRequests": "Всего запросов", + "totalBytes": "Всего передано данных", + "zoneInfo": "Информация о зоне", + "addDomain": "Добавить домен", + "emptyDomains": "Домены еще не добавлены", + "certificate": "Сертификат", + "upstream": "Сервер-источник", + "certNumber": "Сертификат №{id}", + "unbound": "Несвязанный", + "unlinkedRoute": "Нет связанного маршрута", + "loadingEllipsis": "Загрузка…", + "upstreamUnset": "Восходящий поток не настроен", + "viewRoute": "Посмотреть детали маршрута", + "deleteDomain": "Удалить домен", + "domainDeleted": "Домен удален", + "deleteFailed": "Удаление не удалось", + "deleteDomainTitle": "Удалить домен", + "deleteDomainDesc": "Удалить {domain}? Это невозможно отменить.", + "zoneCertsTitle": "Сертификаты, используемые этой зоной", + "zoneCertsDesc": "Сертификаты управляются в глобальной библиотеке сертификатов. В этом списке показаны только сертификаты, привязанные к доменам в этой зоне.", + "certLibrary": "Библиотека сертификатов", + "unboundCertHint": "Доменов без сертификата: {count}. Выберите сертификат на вкладке «Домены».", + "emptyBoundCerts": "Никаких связанных сертификатов. Выберите сертификат в строке домена или импортируйте/примените его в библиотеке сертификатов.", + "status": "Статус", + "coverage": "Охватываемые домены", + "expiresAt": "Срок действия истекает", + "selectZone": "Сначала выберите зону", + "enterDomain": "Введите домен", + "noWildcard": "Домены зон не поддерживают подстановочные знаки", + "invalidFormat": "Неверный формат домена", + "invalidSubdomain": "Неверный формат имени субдомена", + "mustBelongToZone": "Домен должен принадлежать зоне {root}", + "domainAdded": "Домен добавлен", + "addFailed": "Добавить не удалось", + "quickCreateTitle": "Быстрое добавление домена", + "quickCreateDesc": "После выбора зоны введите имя субдомена (например, API), @ (корневой) или полное FQDN.", + "selectRegisteredRoot": "Выберите зарегистрированный корневой домен", + "zoneLabel": "Зона:", + "domain": "Домен", + "domainPlaceholderWithZone": "API или @ или API.{domain}", + "domainPlaceholder": "API/@/полный домен", + "willCreate": "Создаст:", + "domainExample": "Пример: введите api → api.zone.com; @ → сам корень", + "certOptional": "Сертификат (необязательно)", + "noCert": "Нет сертификата" + }, + "certificates": { + "title": "Сертификаты", + "refresh": "Обновить сертификаты", + "importCert": "Импортный сертификат", + "applyTitle": "Подать заявку на сертификат", + "listTitle": "Список сертификатов", + "listDesc": "Показывает срок действия сертификата, примечания и статус. Вы можете просмотреть подробную информацию, отредактировать содержимое или удалить сертификаты.", + "loadingList": "Загрузка списка сертификатов...", + "requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.", + "emptyList": "Сертификатов пока нет. Нажмите «Импортировать сертификат» или «Подать заявку на сертификат» в правом верхнем углу, чтобы начать.", + "notBeforeLabel": "Действительно с: {value}", + "notAfterLabel": "Срок действия истекает: {value}", + "sourceLabel": "Источник: {value}", + "sourceAcme": "Выпущен через ACME", + "sourceUpload": "Загрузка вручную", + "statusLabel": "Статус: {value}", + "converting": "Выполняется конвертация...", + "applying": "Применение...", + "statusError": "Статус: {value} ({message})", + "convertFailed": "Преобразование не удалось", + "applyFailed": "Не удалось выпустить сертификат", + "remarkLabel": "Примечание: {value}", + "noRemark": "Нет примечания", + "view": "Посмотреть", + "edit": "Изменить", + "renew": "Продлить", + "deleted": "Сертификат удален.", + "renewSubmitted": "Задача продления отправлена на сертификат {name}", + "imported": "Сертификат {name} импортирован.", + "applySubmitted": "Задача заявки отправлена на сертификат {name}", + "convertSubmitted": "Заявка на конвертацию подана для получения сертификата {name}", + "reapplySubmitted": "Сертификат {name} обновлен и применяется повторно...", + "updated": "Сертификат {name} обновлен.", + "deleteTitle": "Удалить сертификат", + "deleteDesc": "Удалить сертификат {name}?", + "applyEditTitle": "Изменить и повторно применить сертификат", + "applyConvertTitle": "Перевыпустить сертификат через ACME", + "applyEditDesc": "Измените настройки сертификата ACME. После сохранения сертификат будет повторно применен с новыми настройками.", + "applyConvertDesc": "Укажите параметры выпуска через ACME. После успешного выпуска текущий сертификат будет заменён на сертификат с автоматическим продлением.", + "applyDesc": "Используйте ACME (Let's Encrypt и другие), чтобы автоматически подавать заявки на получение и продление сертификатов, включая домены с подстановочными знаками.", + "name": "Название сертификата", + "namePlaceholder": "Например: сертификат основного сайта.", + "primaryDomain": "Основной домен", + "primaryDomainPlaceholder": "example.com или *.example.com", + "otherDomains": "Другие домены", + "otherDomainsHint": "Один домен на строку. Для сертификата с подстановочным знаком также введите соответствующий корневой домен, чтобы его можно было выдать вместе.", + "dnsAccount": "DNS аккаунт провайдера", + "selectDnsAccount": "Выберите учетную запись DNS.", + "keyAlgorithm": "Ключевой алгоритм", + "remark": "Примечание", + "remarkPlaceholder": "Необязательно. Запишите, для чего используется этот сертификат.", + "autoRenew": "Включить автоматическое продление", + "autoRenewDesc": "Если эта опция включена, сертификат продлевается автоматически за 7 дней до истечения срока его действия.", + "advanced": "Расширенные параметры", + "dnsServer1": "DNS сервер проверки 1", + "dnsServer2": "DNS сервер проверки 2", + "dnsServerPlaceholder": "Оставьте пустым, чтобы использовать авторитетное значение по умолчанию DNS", + "skipCname": "Пропустить проверку CNAME", + "skipCnameDesc": "Не следуйте записям CNAME во время проверки DNS-01.", + "skipDns": "Пропустить предварительную проверку DNS", + "skipDnsDesc": "Попросите Let’s Encrypt проверить напрямую, без локальной проверки.", + "submitting": "Отправка...", + "saveAndApply": "Сохраните и примените", + "startConvert": "Начать преобразование", + "startApply": "Выпустить сертификат", + "editTitle": "Изменить сертификат", + "editDesc": "Вы можете изменить имя и примечание сертификата, а также повторно загрузить сертификат PEM и закрытый ключ.", + "loadingContent": "Загрузка содержимого сертификата...", + "notFound": "Сертификат не найден. Возможно, оно было удалено.", + "certPem": "Сертификат PEM", + "keyPem": "Закрытый ключ PEM", + "convertSource": "Преобразовать источник", + "saving": "Сохранение...", + "saveCert": "Сохранить сертификат", + "importTitle": "Добавить сертификат", + "importDesc": "Вставьте PEM вручную или загрузите файлы сертификатов. После импорта его можно сразу выбрать в формах сайта.", + "manualImport": "Ручной импорт", + "fileImport": "Импорт файла", + "remarkProdPlaceholder": "Например: производственный сертификат для основного объекта.", + "importing": "Импорт...", + "remarkWildcardPlaceholder": "Например: производственный сертификат с подстановочным знаком.", + "certFile": "Файл сертификата", + "keyFile": "Файл закрытого ключа", + "fileSelected": "Выбрано: {name}", + "selectCertFile": "Выберите файл PEM/CRT.", + "selectKeyFile": "Выберите файл KEY/PEM.", + "uploading": "Загрузка...", + "uploadFiles": "Загрузить файлы", + "clearFiles": "Очистить файлы", + "selectFiles": "Выберите файл сертификата и файл закрытого ключа.", + "detailTitle": "Детали сертификата", + "detailDesc": "Просмотр метаданных сертификата, примечаний и текущего сохраненного содержимого PEM.", + "loadingDetail": "Загрузка сведений о сертификате...", + "status": "Статус", + "notBefore": "Действительно с", + "notAfter": "Срок действия истекает", + "certPemCopied": "Сертификат PEM скопирован.", + "keyPemCopied": "Закрытый ключ PEM скопирован.", + "copy": "Копировать", + "close": "Закрыть", + "editCert": "Изменить сертификат", + "deleting": "Удаление...", + "deleteCert": "Удалить сертификат", + "certStatus": { + "unknownExpiry": "Срок действия неизвестен", + "expired": "Срок действия истек", + "expiresInDays": "Срок действия истекает через {days, plural, one {# день} few {# дня} many {# дней} other {# дня}}.", + "valid": "Действителен" + }, + "validation": { + "nameRequired": "Введите имя сертификата", + "nameTooLong": "Имя сертификата не может превышать 255 символов.", + "certPemRequired": "Введите сертификат PEM", + "keyPemRequired": "Введите закрытый ключ PEM", + "remarkTooLong": "Примечание не может превышать 255 символов.", + "primaryDomainRequired": "Введите основной домен", + "dnsAccountRequired": "Выберите учетную запись DNS." + } + }, + "dnsAccounts": { + "title": "DNS аккаунтов", + "addAccount": "Добавить аккаунт", + "listTitle": "DNS список аккаунтов", + "listDesc": "Централизованно управляйте учетными записями поставщиков DNS, используемыми для проверки сертификата DNS ACME.", + "loadingList": "Загрузка DNS аккаунтов...", + "requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.", + "emptyList": "Аккаунтов DNS пока нет. Нажмите «Добавить учетную запись» в правом верхнем углу, чтобы начать.", + "createdAt": "Создано: {date}", + "created": "DNS аккаунт добавлен", + "deleted": "DNS аккаунт удален", + "deleteTitle": "Удалить учетную запись DNS", + "deleteDesc": "Удалить учетную запись DNS {name}?", + "createTitle": "Добавить аккаунт DNS", + "createDesc": "Централизованно управляйте учетными записями поставщиков DNS, используемыми для проверки сертификата DNS ACME.", + "accountName": "Имя учетной записи", + "accountNamePlaceholder": "Cloudflare адрес электронной почты", + "provider": "DNS провайдер", + "tokenPlaceholder": "Не используйте глобальный ключ API.", + "nameRequired": "Введите имя", + "tokenRequired": "Введите токен", + "submitting": "Отправка...", + "submit": "Отправить" + }, + "origins": { + "title": "Серверы-источники", + "subtitle": "Общий каталог серверов-источников для маршрутов. Измените адрес один раз для всех связанных маршрутов.", + "refresh": "Обновить", + "create": "Добавить сервер-источник", + "loadFailed": "Загрузка не удалась", + "emptyTitle": "Нет серверов-источников", + "emptyDesc": "Добавьте сервер-источник, чтобы использовать его адрес в маршрутах.", + "routeCount": "Маршрутов: {count}", + "noRemark": "Нет примечания", + "lastUpdated": "Последнее обновление: {date}", + "detail": "Подробности", + "edit": "Изменить", + "deleted": "Сервер-источник удалён", + "deleteFailed": "Удаление не удалось", + "deleteTitle": "Удалить сервер-источник", + "deleteDesc": "Удалить сервер-источник «{name}»?", + "confirmDelete": "Удалить", + "missingId": "Не указан корректный ID сервера-источника.", + "loadingDetail": "Загрузка сервера-источника…", + "backToList": "Вернуться к списку", + "notFound": "Сервер-источник не найден или уже удалён.", + "editOrigin": "Изменить сервер-источник", + "deleteOrigin": "Удалить сервер-источник", + "boundRoutes": "Связанные маршруты", + "createdAt": "Создано", + "updatedAt": "Обновлено", + "remark": "Примечание", + "relatedRoutes": "Связанные маршруты", + "relatedRoutesDesc": "Маршруты, использующие этот сервер-источник в качестве основного.", + "noRelatedRoutes": "Нет связанных маршрутов", + "noRelatedRoutesDesc": "Этот сервер-источник пока не используется ни в одном маршруте.", + "domain": "Домен", + "address": "Адрес сервера-источника", + "status": "Статус", + "enabled": "Включено", + "disabled": "Отключено", + "addressRequired": "Укажите адрес сервера-источника", + "addressInvalid": "Неверный формат адреса сервера-источника.", + "updated": "Сервер-источник обновлён", + "created": "Сервер-источник создан", + "saveFailed": "Сохранить не удалось", + "editorDesc": "Каталог адресов серверов-источников для повторного использования в маршрутах. Протокол и порт задаются в самом маршруте.", + "name": "Название сервера-источника", + "namePlaceholder": "Основной сервер-источник", + "saving": "Сохранение...", + "saveChanges": "Сохранить изменения" + }, + "proxyRoutes": { + "title": "Маршруты прокси", + "refresh": "Обновить", + "createRule": "Новый маршрут", + "listTitle": "Список маршрутов", + "searchPlaceholder": "Поиск по сайтам, доменам или серверам-источникам…", + "emptyTitle": "Нет веб-сайтов", + "emptyDesc": "Сначала создайте веб-сайт, затем откройте страницу конфигурации, чтобы завершить настройку HTTPS, кэша и ограничений скорости.", + "noMatches": "Нет соответствующих результатов", + "noMatchesDesc": "Попробуйте другой поисковый запрос или очистите фильтр.", + "siteName": "Название сайта", + "domain": "Домен", + "status": "Статус", + "upstream": "Сервер-источник", + "actions": "Действия", + "enabled": "Включено", + "disabled": "Отключено", + "configure": "Настроить", + "siteCreated": "Сайт создан", + "siteDeleted": "Сайт удален", + "deleteFailed": "Удаление не удалось", + "loadListFailed": "Не удалось загрузить список маршрутов.", + "unknownError": "Неизвестная ошибка", + "deleteSiteTitle": "Удалить сайт", + "deleteSiteDesc": "Удалить сайт {name}? При этом будут удалены все домены и настройки сайта.", + "deleting": "Удаление...", + "missingId": "Действительный маршрут ID отсутствует.", + "notFound": "Маршрут не найден", + "notFoundDesc": "Вернитесь к списку и повторите попытку.", + "saving": "Сохранение...", + "saveFailed": "Сохранить не удалось", + "requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.", + "back": "Назад", + "publish": "Опубликовать конфигурацию", + "diffFailed": "Не удалось загрузить разницу конфигурации.", + "noPublishNeeded": "Текущая конфигурация соответствует активной версии. Нечего публиковать.", + "publishSuccess": "Опубликовано успешно", + "versionLabel": "Версия {version}", + "publishFailed": "Опубликовать не удалось", + "publishTitle": "Опубликовать конфигурацию", + "publishDesc": "Это создаст новую версию из ожидающей конфигурации и сделает ее активной. Затем узлы получат обновление.", + "confirmPublish": "Опубликовать", + "createDesc": "Выбирайте привязки из зарегистрированных доменов Зоны. После создания маршрута вы можете продолжить настройку кэша, ограничений скорости и других дополнительных параметров.", + "siteId": "Идентификатор сайта", + "siteIdOptionalDesc": "Необязательно. Если оставить пустым, автоматически будет использоваться первый домен.", + "siteIdStableDesc": "Используйте стабильный и читаемый бизнес-идентификатор. Он не обязательно должен точно совпадать с доменом.", + "bindDomains": "Связанные домены", + "bindDomainsHint": "Выберите зарегистрированные домены для привязки. Используйте «Быстрое добавление домена», чтобы создать FQDN под зоной и выбрать его автоматически.", + "upstreamType": "Исходный режим", + "upstreamDirect": "Прямое подключение к серверу-источнику", + "upstreamTunnel": "Туннель", + "upstreamPages": "Страницы статического сайта", + "upstreamAddresses": "Восходящие адреса", + "upstreamUrlsHint": "Полный URL на каждой строке. Первая строка — основной сервер-источник. Для нескольких серверов используйте один протокол, без пути и параметров запроса.", + "selectTunnel": "Выберите туннель", + "pleaseSelect": "Пожалуйста, выберите...", + "online": "Онлайн", + "offline": "Оффлайн", + "tunnelForwardHint": "Пересылать запросы на клиентский узел, подключенный этим туннелем.", + "tunnelProtocol": "Внутренний протокол обслуживания", + "tunnelAddress": "Адрес внутреннего сервиса", + "tunnelAddressHint": "Например: 127.0.0.1:8080 или 192.168.1.10:80.", + "selectPagesProject": "Выберите проект Pages", + "pagesProjectHint": "Показаны только включенные проекты Pages с активным развертыванием.", + "enableSite": "Включить сайт", + "enableSiteDesc": "Если этот параметр отключен, конфигурация сохраняется, но не включается в публикацию.", + "createFailed": "Создать не удалось. Пожалуйста, повторите попытку позже.", + "creating": "Создание…", + "create": "Создать", + "searchDomainOrZone": "Поиск домена или зоны…", + "quickAddDomain": "Быстрое добавление домена", + "noZoneDomains": "Нет доступных доменов зоны. Сначала добавьте FQDN в Управление веб-сайтами или используйте «Быстрое добавление домена».", + "noBindableDomains": "Нет привязываемых доменов (остальные уже привязаны к другим маршрутам). Используйте «Быстрое добавление домена», чтобы создать его.", + "noMatchingDomains": "Нет подходящих доменов", + "certNumber": "Сертификат №{id}", + "noCert": "Нет сертификата", + "zoneName": "Зона {name}", + "zoneId": "Зона №{id}", + "unboundDomain": "Нет привязки к домену", + "pagesProjectId": "Проект страниц №{id}", + "pagesUnbound": "Проект страниц не связан", + "tunnelTargetUnset": "Цель не настроена", + "upstreamUnset": "Восходящий поток не настроен", + "domainSettings": "Настройки домена", + "domainSettingsDesc": "Привяжите полные доменные имена, уже зарегистрированные в зоне. Поддерживать сертификаты в управлении доменами зоны.", + "redirectNeedsCert": "Привяжите сертификат к выбранным доменам (в Зоне) перед включением перенаправления HTTP.", + "domainSettingsSaved": "Настройки домена сохранены.", + "redirectHttp": "Автоматически перенаправить HTTP на HTTPS", + "redirectHttpEnabledHint": "Если этот параметр включен, генерируются дополнительные правила перенаправления порта 80.", + "redirectHttpNeedsCert": "Прежде чем эту функцию можно будет включить, хотя бы один выбранный домен должен иметь привязанный сертификат.", + "reverseProxy": "Обратный прокси", + "reverseProxyDesc": "Настройте адреса серверов-источников и стратегию выбора сервера.", + "enableHttp2": "Включить HTTP/2", + "enableHttp2Desc": "Включено по умолчанию. Если этот параметр отключен, трафик HTTPS не будет использовать HTTP/2.", + "proxySaved": "Настройки обратного прокси сохранены.", + "originHostHint": "Если оставить пустым, запрос имени хоста $host передается по умолчанию.", + "customHeaders": "Пользовательские заголовки запросов", + "customHeadersHint": "По одному на строку в формате Ключ: Значение.", + "limits": "Ограничения трафика", + "limitsDesc": "Лимиты запросов для сайта. Пустое значение или 0 — глобальные настройки; -1 — отключено; положительное значение — собственный лимит.", + "limitsSaved": "Лимиты трафика сохранены", + "connLimit": "Предел параллелизма", + "connLimitHint": "Пусто или 0 наследует глобальное значение по умолчанию; -1 выключает его; Значение больше 0 — это пользовательское ограничение параллелизма.", + "ipLimit": "Лимит на IP", + "ipLimitHint": "Пусто или 0 наследует глобальное значение по умолчанию; -1 выключает его; Значение больше 0 — это индивидуальное ограничение на IP.", + "rateLimit": "Ограничение скорости", + "rateLimitPlaceholder": "512k/1m или -1", + "rateLimitHint": "Пусто или 0 наследует глобальное значение по умолчанию; -1 выключает его; например, 512k или 1m — это пользовательская полоса пропускания.", + "reqLimit": "Скорость запроса на IP", + "reqLimitPlaceholder": "10r/s / 100r/m или -1", + "reqLimitHint": "Пустое значение или 0 — глобальные настройки; -1 — отключено; например, 10r/s или 100r/m задаёт собственную частоту запросов.", + "cache": "Кэш", + "cacheDesc": "Настройте политику пограничного кэша сайта.", + "cacheSaved": "Настройки кэша сохранены.", + "enableCache": "Включить кэш сайта", + "enableCacheDesc": "Новые сайты должны использовать «Стандартные статические ресурсы».", + "cachePolicy": "Политика кэширования", + "policyStatic": "Стандартные статические ресурсы (рекомендуется)", + "policyAll": "Все кэшируемое GET (дополнительно)", + "policySuffix": "Пользовательский суффикс", + "policyPrefix": "Префикс пути", + "policyExact": "Точный путь", + "advancedRiskTitle": "Расширенный политический риск", + "advancedRiskDesc": "Персонализированный HTML может быть кэширован на периферии и передан другим пользователям, если источник не заявляет о приватности/отсутствии хранилища. Подробности смотрите в справке рядом с заголовком.", + "cacheRules": "Правила кэширования", + "cacheHelpTitle": "Примечания по использованию кэша", + "cacheHelpIntro": "Расширение/политика решает, можно ли кэшировать ответ. Заголовки источника и Set-Cookie решают, будет ли он сохранен. Также необходимо включить глобальный кэш OpenResty в настройках производительности.", + "cacheHelpRecommendLabel": "Рекомендуемая политика:", + "cacheHelpRecommend": "Новые сайты должны использовать «Стандартные статические ресурсы» (включая css/js/map/images/fonts/media, исключая HTML/JSON).", + "cacheHelpGeneralLabel": "Общие правила:", + "cacheHelpGeneral": "Номера, отличные от GET, не кэшируются; файлы cookie входа не пропускают кэш самостоятельно; origin Private/no-store или ответ Set-Cookie не записывается на край.", + "cacheHelpAllGetLabel": "Все кэшируемое GET:", + "cacheHelpAllGet": "Расширенный вариант, аналогичный «Кэшировать все». Персонализированные страницы должны быть объявлены источником как частные/не храниться, иначе HTML может быть кэшировано в Edge и предоставлено другим пользователям.", + "cacheHelpCustomLabel": "Пользовательские правила:", + "cacheHelpCustom": "Используйте суффиксы, такие как jpg/css/js; префиксы путей, такие как /assets; точные пути, такие как /robots.txt. После сохранения повторно опубликуйте версию конфигурации, прежде чем она вступит в силу на узлах.", + "cacheHintSuffix": "По одному суффиксу в строке, например jpg, css, js.", + "cacheHintPrefix": "Один префикс пути в каждой строке, например /assets, /static.", + "cacheHintExact": "Один точный путь в каждой строке, например /robots.txt.", + "cacheHintStatic": "Стандартные статические ресурсы используют встроенный список расширений. Никаких дополнительных правил не требуется.", + "cacheHintNone": "Текущая политика не требует дополнительных правил пути.", + "cachePlaceholderNone": "Никаких дополнительных правил для текущей политики.", + "auth": "Аутентификация", + "authDesc": "Настройте Basic Auth, чтобы ограничить доступ. Защита PoW настраивается в группе правил WAF.", + "authSaved": "Настройки аутентификации сохранены.", + "authFormInvalid": "Проверьте форму аутентификации", + "authMode": "Режим аутентификации", + "authNone": "Нет аутентификации", + "powHint": "Защиту PoW можно включить только в группе правил WAF и привязать к сайту.", + "username": "Имя пользователя", + "password": "Пароль", + "wafDesc": "Глобальные группы правил применяются всегда. Здесь вы можете добавить собственные группы правил для текущего сайта.", + "wafUpdated": "WAF группы правил обновлены", + "loadingWaf": "Загрузка групп правил WAF...", + "alwaysOn": "Всегда включен", + "selectCustomRules": "Выберите специальные правила", + "selectCustomRulesDesc": "Выбранные правила выполняются в порядке выполнения, указанном ниже.", + "selectRule": "Выберите {name}", + "ruleEnabled": "Включено", + "ruleDisabled": "Отключено", + "nodeCount": "Узлов: {count}", + "executionOrder": "Порядок выполнения", + "executionOrderDesc": "Пользовательские правила выполняются в этом порядке. Перетащите их или используйте кнопки вверх/вниз, чтобы изменить порядок.", + "noCustomWaf": "Нет пользовательских групп правил WAF.", + "dragRule": "Перетащите {name}", + "moveUpRule": "Переместить {name} вверх.", + "moveDownRule": "Переместите {name} вниз.", + "sections": { + "domains": "Настройки домена", + "domainsDesc": "Сохраняйте идентификатор сайта и выбирайте привязанные домены из зоны.", + "limits": "Ограничения трафика", + "limitsDesc": "Установите ограничения на соединения и ограничения скорости (можно наследовать глобальные значения по умолчанию).", + "proxy": "Обратный прокси", + "proxyDesc": "Настройте основной сервер-источник и дополнительные адреса.", + "cache": "Кэш", + "cacheDesc": "Настройте политику кэширования сайта.", + "waf": "WAF", + "wafDesc": "Свяжите группы правил WAF и просмотрите политики, применимые к этому сайту.", + "auth": "Аутентификация", + "authDesc": "Настройте базовую аутентификацию, чтобы посетители должны были вводить имя пользователя и пароль." + }, + "validation": { + "enterDomain": "Введите домен", + "invalidDomain": "Неверный формат домена", + "enterAtLeastOneDomain": "Введите хотя бы один домен", + "invalidDomainWithValue": "Неверный формат домена: {domain}", + "duplicateDomain": "Дубликат домена: {domain}", + "enterAtLeastOneUpstream": "Укажите хотя бы один адрес сервера-источника", + "invalidUpstream": "Неверный формат адреса сервера-источника: {url}", + "upstreamMustBeHttp": "Адрес сервера-источника должен начинаться с http:// или https://: {url}", + "upstreamMissingHost": "В адресе сервера-источника не указано имя хоста: {url}", + "multiUpstreamNoPath": "При использовании нескольких серверов-источников адреса не должны содержать путь или параметры запроса.", + "multiUpstreamSameProtocol": "Все серверы-источники одного сайта должны использовать одинаковый протокол.", + "invalidOriginHost": "Неверный исходный формат хоста", + "invalidHeader": "Неверный формат заголовка пользовательского запроса: {line}", + "invalidHeaderName": "Недопустимое имя заголовка пользовательского запроса: {key}", + "invalidLimitRate": "Неверный формат ограничения скорости. Используйте 512k, 1m, число или -1, чтобы отключить его.", + "invalidLimitReq": "Неверный формат частоты запросов. Используйте 10r/s, 100r/m или -1 для отключения.", + "cacheRuleRequired": "Для текущей политики кэширования требуется хотя бы одно правило.", + "invalidCacheSuffix": "Неверный формат суффикса кэша: {rule}", + "invalidCachePath": "Неверный формат правила пути к кэшу: {rule}", + "siteNameTooLong": "Идентификатор сайта не может превышать 255 символов.", + "selectAtLeastOneDomain": "Выберите хотя бы один домен", + "selectTunnel": "Выберите туннель", + "enterTunnelTarget": "Введите внутренний адрес службы (например, 127.0.0.1:8080).", + "selectPagesProject": "Выберите проект Pages", + "enterSiteId": "Введите идентификатор сайта", + "enterIntegerOrMinusOne": "Введите -1, 0 или целое положительное число.", + "enterUsername": "Введите имя пользователя", + "enterPassword": "Введите пароль" + } + }, + "responses": { + "title": "Страницы ответов", + "subtitle": "Настройте страницы ошибок сервера-источника и страницы для работы без сети.", + "loadingPermission": "Загрузка информации о разрешении...", + "loadingConfig": "Загрузка настроек страницы ответов...", + "loadingPage": "Загрузка страниц ответов...", + "loadingErrorConfig": "Загрузка настроек страницы ошибки...", + "loadingOfflineConfig": "Загрузка настроек офлайн-страницы...", + "loadFailed": "Загрузка не удалась", + "forbidden": "Недостаточное разрешение", + "forbiddenSettings": "Доступ к настройкам страницы ответов имеют только администраторы.", + "forbiddenPreviewError": "Только администраторы могут просматривать страницу ошибки.", + "forbiddenPreviewOffline": "Только администраторы могут просматривать автономную страницу.", + "forbiddenEditError": "Только администраторы могут редактировать страницу ошибки HTML.", + "forbiddenEditOffline": "Только администраторы могут редактировать автономную страницу HTML.", + "errorPage": "Страница ошибки", + "offlinePage": "Офлайн-страница", + "saveFailed": "Сохранить не удалось", + "policySaved": "Политика триггера сохранена. Опубликуйте версию, чтобы конфигурация вступила в силу.", + "triggerPolicy": "Триггерная политика", + "triggerPolicyDesc": "Настройте, когда страница исходной ошибки включена и какие коды состояния ее запускают.", + "enableErrorPage": "Показывать страницу ошибки сервера-источника", + "enableErrorPageDesc": "Если этот параметр отключен, передается исходный ответ или ответ об ошибке Nginx по умолчанию.", + "getOnly": "GET только запросы", + "getOnlyDesc": "Если этот параметр включен, пользовательская страница ошибок возвращается только для сопоставления кодов состояния ошибок по запросам GET.", + "statusCodes": "Коды состояния триггера", + "statusCodesDesc": "Поддерживает один код (например, 502) или закрытый диапазон (например, 500–599).", + "statusCodesPlaceholder": "Например 502 или 500-599, затем нажмите Enter.", + "pagePreview": "Предварительный просмотр страницы", + "preview": "Предпросмотр", + "edit": "Изменить", + "errorPagePreview": "Предпросмотр страницы ошибки сервера-источника", + "errorPreview": "Предварительный просмотр страницы ошибки", + "offlinePreview": "Предварительный просмотр страницы в автономном режиме", + "offlinePageDesc": "Если этот параметр включен, Service Worker выдается веб-сайтам HTTPS. Если домен недоступен, браузер отображает эту автономную страницу из кэша.", + "offlineSaved": "Офлайн-страница сохранена. Опубликуйте версию, чтобы конфигурация вступила в силу.", + "enableOffline": "Включить автономную страницу Service Worker", + "enableOfflineDesc": "Вступает в силу только на веб-сайтах HTTPS.", + "enableOfflineAria": "Включить автономную страницу", + "scope": "Область применения", + "scopeDesc": "Применяется только к выбранным доменам HTTPS. Если пусто, ничего не вводится.", + "addDomain": "Добавить домен", + "noScopeSelected": "Домены области действия не выбраны. После сохранения ни на один сайт ничего не внедряется.", + "enableThenSelectScope": "Включите резервный режим автономного режима перед выбором доменов области.", + "removeDomain": "Удалить {domain}", + "selectScopeTitle": "Выберите домены области действия", + "selectScopeDesc": "Внедрите резервный вариант автономного режима только для выбранных доменов HTTPS. Поиск, фильтрация и массовый выбор.", + "domain": "Домен", + "searchDomainOrZone": "Поиск домена или апекса…", + "selectedCount": "Выбрано: {count}", + "visibleCount": "Показано: {count}", + "unselectVisible": "Снять выбор с видимых доменов", + "selectVisible": "Выбрать видимый", + "clear": "Очистить", + "noAvailableDomains": "Нет доступных доменов. Сначала зарегистрируйте домены в разделе «Управление зонами».", + "noMatchingDomains": "Нет подходящих доменов", + "selectZone": "Выберите вершину {domain}", + "apex": "Апекс", + "confirm": "OK", + "backToError": "Вернуться на страницу ошибки", + "backToOffline": "Вернуться на офлайн-страницу", + "closePreview": "Закрыть предварительный просмотр", + "fullscreen": "Полноэкранный просмотр", + "htmlTooLarge": "HTML превышает ограничение размера (максимум 256 КБ)", + "errorHtmlSaved": "Страница исходной ошибки HTML сохранена. Опубликуйте версию, чтобы конфигурация вступила в силу.", + "offlineHtmlSaved": "Офлайн-страница HTML сохранена. Опубликуйте версию, чтобы конфигурация вступила в силу.", + "templateNotFound": "Выбранный шаблон не найден", + "templateLoaded": "Загрузил шаблон «{name}» в редактор.", + "errorRestored": "Снято для использования встроенного шаблона сервера по умолчанию (сохраните, чтобы применить).", + "offlineRestored": "Снято для использования встроенного шаблона по умолчанию (сохраните, чтобы применить)", + "editErrorHtml": "Изменить страницу ошибки HTML", + "editOfflineHtml": "Редактировать офлайн-страницу HTML", + "offlineLivePreview": "Предварительный просмотр оффлайн-страницы", + "builtinTemplates": "Встроенные шаблоны", + "selectTemplate": "Выберите шаблон", + "loadTemplate": "Загрузить шаблон", + "restoreDefault": "Восстановить настройки по умолчанию", + "restoreTitle": "Восстановить значение по умолчанию HTML?", + "restoreErrorDesc": "Это очищает редактор. После сохранения используется встроенный шаблон сервера по умолчанию. Это действие не сохраняется автоматически.", + "restoreOfflineDesc": "Это очищает редактор. После сохранения используется встроенный шаблон по умолчанию. Это действие не сохраняется автоматически.", + "confirmRestore": "Восстановить" + } +} diff --git a/frontend/package.json b/frontend/package.json index cd8d12d5..a62894f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "lint": "eslint", "format": "biome format --write .", "format:check": "biome format .", - "check:i18n": "node scripts/check-i18n-keys.mjs" + "check:i18n": "node scripts/check-i18n-keys.mjs && node scripts/check-runtime-language.mjs" }, "dependencies": { "@codemirror/lang-html": "^6.4.12", diff --git a/frontend/proxy.ts b/frontend/proxy.ts index f9cc8581..b404b406 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -1,5 +1,11 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { + localeCookieName, + normalizeLocale, + pickLocaleFromAcceptLanguage, +} from '@/i18n/config'; +import { getRuntimeTranslator } from '@/i18n/runtime-translators'; /** * Next.js 16 代理层 @@ -94,10 +100,20 @@ export function proxy(request: NextRequest) { const [allowed, waitTime] = checkRateLimit(identifier, pathname); if (!allowed) { + const cookieLocale = request.cookies.get(localeCookieName)?.value; + const locale = cookieLocale + ? normalizeLocale(cookieLocale) + : pickLocaleFromAcceptLanguage( + request.headers.get('accept-language'), + ); + const translate = getRuntimeTranslator(locale); return NextResponse.json( { error_code: 'RATE_LIMITED', - error_msg: `请求过于频繁,请 ${waitTime} 秒后重试`, + error_msg: translate( + 'too_many_requests_retry_in_value0_seconds_ab5e78f1', + { value0: waitTime }, + ), }, { status: 429, headers: { 'Retry-After': String(waitTime) } }, ); diff --git a/frontend/scripts/check-i18n-keys.mjs b/frontend/scripts/check-i18n-keys.mjs index 13befdb6..cd7fd4a8 100644 --- a/frontend/scripts/check-i18n-keys.mjs +++ b/frontend/scripts/check-i18n-keys.mjs @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { messageContractTokens } from './message-contract.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -17,20 +18,34 @@ function flatten(obj, prefix = '') { return keys; } -const zh = JSON.parse( - readFileSync(resolve(root, 'messages/zh-CN.json'), 'utf8'), -); const en = JSON.parse(readFileSync(resolve(root, 'messages/en.json'), 'utf8')); -const zhKeys = flatten(zh); const enKeys = flatten(en); -const onlyZh = [...zhKeys].filter((k) => !enKeys.has(k)).sort(); -const onlyEnFixed = [...enKeys].filter((k) => !zhKeys.has(k)).sort(); - -if (onlyZh.length || onlyEnFixed.length) { - console.error('i18n key mismatch'); - if (onlyZh.length) console.error('only in zh-CN:', onlyZh); - if (onlyEnFixed.length) console.error('only in en:', onlyEnFixed); - process.exit(1); +for (const locale of ['zh-CN', 'ru']) { + const messages = JSON.parse( + readFileSync(resolve(root, `messages/${locale}.json`), 'utf8'), + ); + const keys = flatten(messages); + const extra = [...keys].filter((key) => !enKeys.has(key)).sort(); + const missing = [...enKeys].filter((key) => !keys.has(key)).sort(); + if (extra.length || missing.length) { + console.error(`i18n key mismatch: ${locale}`, { extra, missing }); + process.exitCode = 1; + } + for (const key of enKeys) { + const get = (source) => + key.split('.').reduce((value, part) => value?.[part], source); + const source = get(en); + const translated = get(messages); + if ( + typeof source === 'string' && + typeof translated === 'string' && + JSON.stringify(messageContractTokens(source)) !== + JSON.stringify(messageContractTokens(translated)) + ) { + console.error(`${locale} changes template variables or tags in ${key}`); + process.exitCode = 1; + } + } } - -console.log(`i18n keys OK (${zhKeys.size} keys)`); +if (!process.exitCode) + console.log(`i18n keys OK (${enKeys.size} keys, 3 locales)`); diff --git a/frontend/scripts/check-runtime-language.mjs b/frontend/scripts/check-runtime-language.mjs new file mode 100644 index 00000000..24680aa3 --- /dev/null +++ b/frontend/scripts/check-runtime-language.mjs @@ -0,0 +1,179 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const root = process.argv[2] + ? resolve(process.argv[2]) + : resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const translatedScript = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Cyrillic}]/u; +const failures = []; +// These six identifiers are persisted upload counter keys. Renaming them is a +// data migration, not a translation. The UI translates their labels separately. +const legacyCategoryKeys = new Set([ + '图片', + '视频', + '音频', + '文档', + '压缩包', + '其他', +]); +const compatibilityFiles = new Set([ + 'internal/apps/upload/stats/category.go', + 'frontend/app/(main)/admin/files/components/file-stats.tsx', +]); + +function report(file, source, offset, text) { + if (!translatedScript.test(text)) return; + const name = relative(root, file).replaceAll('\\', '/'); + if (compatibilityFiles.has(name) && legacyCategoryKeys.has(text)) return; + // Historical persisted notification recipient alias, not display text. + if (name === 'internal/apps/admin/push/logics.go' && text === '系统') return; + const line = source.slice(0, offset).split('\n').length; + failures.push(`${name}:${line}`); +} + +function checkGo(file, source) { + for (let index = 0; index < source.length; ) { + if (source.startsWith('//', index)) { + const end = source.indexOf('\n', index); + index = end < 0 ? source.length : end; + continue; + } + if (source.startsWith('/*', index)) { + const end = source.indexOf('*/', index + 2); + index = end < 0 ? source.length : end + 2; + continue; + } + const quote = source[index]; + if (!['"', '`', "'"].includes(quote)) { + index++; + continue; + } + const start = index++; + while (index < source.length) { + if (quote !== '`' && source[index] === '\\') { + index += 2; + continue; + } + if (source[index++] === quote) break; + } + let text = source.slice(start + 1, index - 1); + // Go struct tags describe schemas/docs, rather than runtime messages. + if ( + quote === '`' && + /^(?:json|gorm|form|binding|validate|mapstructure|example):/.test(text) + ) + continue; + if (quote !== '`') { + text = text.replace( + /(? + String.fromCodePoint(Number.parseInt(short ?? long, 16)), + ); + } + report(file, source, start, text); + } +} + +function inspect(file) { + const source = readFileSync(file, 'utf8'); + if (file.endsWith('.go')) { + checkGo(file, source); + return; + } + if (file.endsWith('.sh')) { + let offset = 0; + for (const line of source.split('\n')) { + if (!line.trimStart().startsWith('#')) report(file, source, offset, line); + offset += line.length + 1; + } + return; + } + const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + function visit(node) { + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + const callee = node.expression.getText(ast); + const isFormatter = + /(?:Intl\.(?:NumberFormat|DateTimeFormat|DisplayNames)|\.toLocale(?:String|DateString|TimeString)|\.localeCompare)$/.test( + callee, + ); + const isFixedChinese = (argument) => + ts.isStringLiteral(argument) && argument.text === 'zh-CN'; + if ( + isFormatter && + node.arguments?.some( + (argument) => + isFixedChinese(argument) || + (ts.isArrayLiteralExpression(argument) && + argument.elements.some(isFixedChinese)), + ) + ) { + failures.push( + `${relative(root, file).replaceAll('\\', '/')}:${source.slice(0, node.getStart(ast)).split('\n').length} (fixed formatting locale)`, + ); + } + } + if ( + ts.isStringLiteral(node) || + ts.isNoSubstitutionTemplateLiteral(node) || + ts.isJsxText(node) || + ts.isTemplateHead(node) || + ts.isTemplateMiddle(node) || + ts.isTemplateTail(node) || + ts.isIdentifier(node) + ) { + report(file, source, node.getStart(ast), node.text); + } + ts.forEachChild(node, visit); + } + visit(ast); +} + +function walk(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if ( + entry.name.startsWith('.') || + ['node_modules', 'messages', 'dist', 'out', 'testhelper'].includes( + entry.name, + ) + ) + continue; + const file = resolve(directory, entry.name); + if (entry.isDirectory()) walk(file); + else if ( + /\.(?:go|tsx?|sh)$/.test(file) && + !/(?:_test\.go|\.(?:test|spec)\.tsx?)$/.test(file) + ) + inspect(file); + } +} + +for (const directory of [ + 'internal', + 'pkg', + 'cmd', + 'scripts', + 'frontend/app', + 'frontend/components', + 'frontend/contexts', + 'frontend/lib', + 'frontend/hooks', + 'frontend/i18n', +]) + walk(resolve(root, directory)); + +for (const file of ['frontend/proxy.ts', 'frontend/next.config.ts']) + inspect(resolve(root, file)); +if (failures.length) { + console.error( + 'Non-English runtime text must be moved into a locale catalog:\n' + + failures.join('\n'), + ); + process.exitCode = 1; +} else { + console.log( + 'Runtime language check passed (legacy persistence keys preserved)', + ); +} diff --git a/frontend/scripts/merge-i18n-fragments.mjs b/frontend/scripts/merge-i18n-fragments.mjs index 26970c36..199ac446 100644 --- a/frontend/scripts/merge-i18n-fragments.mjs +++ b/frontend/scripts/merge-i18n-fragments.mjs @@ -39,4 +39,5 @@ function mergeLocale(locale) { mergeLocale('zh-CN'); mergeLocale('en'); +mergeLocale('ru'); console.log('fragments merged'); diff --git a/frontend/scripts/message-contract.mjs b/frontend/scripts/message-contract.mjs new file mode 100644 index 00000000..f298342c --- /dev/null +++ b/frontend/scripts/message-contract.mjs @@ -0,0 +1,13 @@ +// Names used by renderers are technical tokens, including inside raw examples. +export function messageContractTokens(message) { + return [ + ...message.matchAll( + /\$[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)*/gu, + ), + ...message.matchAll(/<\/?[\p{L}][\p{L}\p{N}_-]*(?:\s[^<>]*?)?\s*\/?>/gu), + ] + .map(([token]) => + token.startsWith('<') ? token.match(/^<\/?[^\s/>]+/u)[0] + '>' : token, + ) + .sort(); +} diff --git a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx index d702341e..9c739bac 100644 --- a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx +++ b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx @@ -264,7 +264,7 @@ describe('Cloudflare group detail move and batch operations', () => { // Select all via header checkbox const selectAllCheckbox = screen.getByRole('checkbox', { - name: 'Select all', + name: zhCN.runtime.ui_selectAll, }); fireEvent.click(selectAllCheckbox); @@ -318,7 +318,9 @@ describe('Cloudflare group detail move and batch operations', () => { expect(await screen.findByText('a.example.com')).toBeVisible(); // Select all members - fireEvent.click(screen.getByRole('checkbox', { name: 'Select all' })); + fireEvent.click( + screen.getByRole('checkbox', { name: zhCN.runtime.ui_selectAll }), + ); expect(await screen.findByText('已选择 2 项')).toBeVisible(); // Click batch move button diff --git a/frontend/tests/openflare/pages-source-ui.test.tsx b/frontend/tests/openflare/pages-source-ui.test.tsx index 74c03c67..f4a96e37 100644 --- a/frontend/tests/openflare/pages-source-ui.test.tsx +++ b/frontend/tests/openflare/pages-source-ui.test.tsx @@ -172,7 +172,9 @@ describe('Pages source UI', () => { await user.click(screen.getByRole('button', { name: '配置' })); expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible(); - expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible(); + expect( + screen.getByRole('radio', { name: zhCN.runtime.ui_remoteUrl }), + ).toBeVisible(); expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible(); expect(screen.getByRole('radio', { name: '手动部署' })).toBeChecked(); expect(screen.queryByText('构建命令')).not.toBeInTheDocument(); @@ -281,7 +283,7 @@ describe('Pages source UI', () => { ).not.toBeInTheDocument(); expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument(); await user.type( - screen.getByLabelText('Release tag'), + screen.getByLabelText(zhCN.runtime.ui_releaseTag), 'release/candidate#1&channel=stable', ); await user.clear(screen.getByLabelText('Release Asset 文件名')); @@ -319,7 +321,10 @@ describe('Pages source UI', () => { 'https://github.com/openflare/site', ); await user.click(screen.getByRole('radio', { name: '固定 Tag' })); - await user.type(screen.getByLabelText('Release tag'), 'release//candidate'); + await user.type( + screen.getByLabelText(zhCN.runtime.ui_releaseTag), + 'release//candidate', + ); await user.clear(screen.getByLabelText('Release Asset 文件名')); await user.type( screen.getByLabelText('Release Asset 文件名'), @@ -514,7 +519,7 @@ describe('Pages source UI', () => { ).toBeVisible(); await user.click(screen.getByRole('button', { name: '配置' })); - const input = screen.getByLabelText('Remote URL'); + const input = screen.getByLabelText(zhCN.runtime.ui_remoteUrl); expect(input).toHaveAttribute('type', 'url'); expect(input).toHaveValue( 'https://artifacts.example.com/site.zip?token=secret', @@ -641,7 +646,7 @@ describe('Pages source UI', () => { expect( await screen.findAllByText('GitHub · v1.2.3 · 手动同步'), ).toHaveLength(2); - expect(screen.getByText('Production')).toBeVisible(); - expect(screen.getByText('All deployments')).toBeVisible(); + expect(screen.getByText(zhCN.runtime.ui_production)).toBeVisible(); + expect(screen.getByText(zhCN.runtime.ui_allDeployments)).toBeVisible(); }); }); diff --git a/internal/apps/admin/db_manage/routers.go b/internal/apps/admin/db_manage/routers.go index 04fe6b6d..40a5ad64 100644 --- a/internal/apps/admin/db_manage/routers.go +++ b/internal/apps/admin/db_manage/routers.go @@ -209,7 +209,7 @@ func getPostgresOverview(gormDB *gorm.DB) DBOverviewResponse { func GetDBOverview(c *gin.Context) { gormDB := db.DB(c.Request.Context()) if gormDB == nil { - response.AbortInternal(c, "数据库未初始化") + response.AbortInternal(c, "Database is not initialized") return } @@ -238,7 +238,7 @@ func GetDBOverview(c *gin.Context) { func ListDBTables(c *gin.Context) { gormDB := db.DB(c.Request.Context()) if gormDB == nil { - response.AbortInternal(c, "数据库未初始化") + response.AbortInternal(c, "Database is not initialized") return } @@ -269,7 +269,7 @@ func GetDBTableData(c *gin.Context) { gormDB := db.DB(c.Request.Context()) if gormDB == nil { - response.AbortInternal(c, "数据库未初始化") + response.AbortInternal(c, "Database is not initialized") return } @@ -442,13 +442,13 @@ func ExecuteSQL(c *gin.Context) { gormDB := db.DB(c.Request.Context()) if gormDB == nil { - response.AbortInternal(c, "数据库未初始化") + response.AbortInternal(c, "Database is not initialized") return } trimmedSQL := strings.TrimSpace(req.SQL) if trimmedSQL == "" { - response.AbortBadRequest(c, "SQL 语句不能为空") + response.AbortBadRequest(c, "SQL statement must not be empty") return } diff --git a/internal/apps/admin/errs.go b/internal/apps/admin/errs.go index c869ed34..ddd95a5c 100644 --- a/internal/apps/admin/errs.go +++ b/internal/apps/admin/errs.go @@ -7,9 +7,9 @@ package admin // 管理后台错误消息常量 const ( - AdminRequired = "未经授权访问" - TokenAdminRequired = "该访问令牌没有管理员权限,无法访问管理端点" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - InvalidAuthSourceID = "认证源 ID 无效" - InvalidCursorParam = "无效的 cursor 参数" - InvalidTaskExecutionID = "无效的任务执行记录 ID" + AdminRequired = "Unauthorized access" + TokenAdminRequired = "This access token has no administrator privileges and cannot access admin endpoints" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + InvalidAuthSourceID = "invalid authentication source ID" + InvalidCursorParam = "Invalid cursor parameter" + InvalidTaskExecutionID = "Invalid task execution ID" ) diff --git a/internal/apps/admin/logs/routers.go b/internal/apps/admin/logs/routers.go index 6a6b4731..4ab18491 100644 --- a/internal/apps/admin/logs/routers.go +++ b/internal/apps/admin/logs/routers.go @@ -165,7 +165,7 @@ func buildAccessLogFilter(ctx context.Context, c *gin.Context) (analyticsmodel.A if username != "" { userIDs, err := repository.ListUserIDsByUsernameContains(ctx, username) if err != nil { - return filter, fmt.Errorf("查询用户信息失败: %w", err) + return filter, fmt.Errorf("failed to query user information: %w", err) } filter.UserIDs = userIDs } @@ -246,8 +246,8 @@ func GetAccessLogs(c *gin.Context) { ctx := c.Request.Context() store, err := logstore.Active(ctx) if err != nil { - logger.ErrorF(ctx, "获取日志存储实例失败: %v", err) - response.AbortWithError(c, http.StatusBadRequest, "日志存储未启用,无法检索访问日志") + logger.ErrorF(ctx, "Failed to get log storage instance: %v", err) + response.AbortWithError(c, http.StatusBadRequest, "Log storage is disabled; access logs cannot be retrieved") return } @@ -348,8 +348,8 @@ func GetLogsAnalytics(c *gin.Context) { ctx := c.Request.Context() store, err := logstore.Active(ctx) if err != nil { - logger.ErrorF(ctx, "获取日志存储实例失败: %v", err) - response.AbortWithError(c, http.StatusBadRequest, "日志存储未启用,无法获取分析数据") + logger.ErrorF(ctx, "Failed to get log storage instance: %v", err) + response.AbortWithError(c, http.StatusBadRequest, "Log storage is disabled; analytics are unavailable") return } @@ -357,7 +357,7 @@ func GetLogsAnalytics(c *gin.Context) { trendPoints, err := store.UserAccessLogs.GetDailyTrend(ctx, analyticsDays) if err != nil { - response.AbortWithError(c, http.StatusInternalServerError, "查询访问趋势失败: "+err.Error()) + response.AbortWithError(c, http.StatusInternalServerError, "Failed to query access trends: "+err.Error()) return } trendList := make([]trendItem, len(trendPoints)) @@ -370,7 +370,7 @@ func GetLogsAnalytics(c *gin.Context) { browserPoints, err := store.UserAccessLogs.GetBrowserDistribution(ctx, startTime) if err != nil { - response.AbortWithError(c, http.StatusInternalServerError, "查询浏览器分布失败: "+err.Error()) + response.AbortWithError(c, http.StatusInternalServerError, "Failed to query browser distribution: "+err.Error()) return } browserList := make([]browserItem, len(browserPoints)) @@ -383,7 +383,7 @@ func GetLogsAnalytics(c *gin.Context) { topUserPoints, err := store.UserAccessLogs.GetTopActiveUsers(ctx, startTime, topActiveLimit) if err != nil { - response.AbortWithError(c, http.StatusInternalServerError, "查询活跃用户失败: "+err.Error()) + response.AbortWithError(c, http.StatusInternalServerError, "Failed to query active users: "+err.Error()) return } diff --git a/internal/apps/admin/push/channels.go b/internal/apps/admin/push/channels.go index 0b205734..86963c9b 100644 --- a/internal/apps/admin/push/channels.go +++ b/internal/apps/admin/push/channels.go @@ -221,9 +221,9 @@ func TestChannel(c *gin.Context) { default: config = pkgpush.Config{Channel: channelCustom, URL: url} customPushReq := CustomPushRequest{ - Title: "通道测试通知", - Content: "这是一条来自系统的消息通道连通性测试消息。", - Description: "系统通道测试", + Title: "Channel test notification", + Content: "This is a system notification channel connectivity test.", + Description: "System channel test", URL: "https://example.com", To: req.Target, } @@ -235,8 +235,8 @@ func TestChannel(c *gin.Context) { Config: config, Target: req.Target, Body: NotificationMessage{ - Title: "通道测试通知", - Content: "这是一条来自系统的消息通道连通性测试消息。", + Title: "Channel test notification", + Content: "This is a system notification channel connectivity test.", Level: defaultLevelInfo, }, Template: renderedJSON, diff --git a/internal/apps/admin/push/channels_definition.go b/internal/apps/admin/push/channels_definition.go index 80646f86..a0824919 100644 --- a/internal/apps/admin/push/channels_definition.go +++ b/internal/apps/admin/push/channels_definition.go @@ -81,24 +81,24 @@ func init() { // Register custom webhook channel RegisterChannelDefinition(Definition{ Type: channelCustom, - Name: "自定义消息通道", - Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。", + Name: "Custom notification channel", + Description: "Send data to an external webhook using a custom HTTP POST request.", Fields: []Field{ { Key: KeyURL, - Label: "请求地址", + Label: "Request URL", Type: TypeText, Required: true, - Placeholder: "在此填写完整的请求地址,必须使用 HTTPS 协议", - Description: "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook", + Placeholder: "Enter the complete request URL using HTTPS", + Description: "Complete HTTPS endpoint URL, for example https://api.example.com/webhook", }, { Key: KeyOther, - Label: "请求体 (JSON)", + Label: "Request body (JSON)", Type: TypeTextarea, Required: true, - Placeholder: "在此输入请求体,支持模板变量,必须为合法的 JSON 格式", - Description: "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}", + Placeholder: "Enter a valid JSON request body; template variables are supported", + Description: `Available variables: $title, $description, $content, $url, $to. Example: {"text": "$content"}`, }, }, }) @@ -106,33 +106,33 @@ func init() { // Register Lark robot channel RegisterChannelDefinition(Definition{ Type: channelLark, - Name: "飞书群机器人", - Description: "配置飞书群自定义机器人的 Webhook 接口投递。", + Name: "Feishu group bot", + Description: "Deliver notifications through a Feishu group bot webhook.", Fields: []Field{ { Key: KeyURL, - Label: "Webhook 地址", + Label: "Webhook URL", Type: TypeText, Required: true, Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN", - Description: "从飞书群机器人设置中复制 of Webhook URL", + Description: "Copy the webhook URL from the Feishu group bot settings", // Note: using 'of' was in feishu.go, let's keep original wording or fix it }, { Key: KeyToken, - Label: "签名校验密钥 (Secret) (可选)", + Label: "Signing secret (optional)", Type: TypeText, Required: false, - Placeholder: "可选,若机器人启用了安全设置中的签名校验,请在此输入", - Description: "飞书群机器人安全设置中的签名校验 Key", + Placeholder: "Enter the secret if signature verification is enabled for the bot", + Description: "Signature verification key from the Feishu group bot security settings", }, { Key: KeyOther, - Label: "自定义卡片 JSON 模版 (可选)", + Label: "Custom card JSON template (optional)", Type: TypeTextarea, Required: false, - Placeholder: "可选,留空则默认使用系统内置的精美互动卡片", - Description: "若填写,必须是合法的飞书卡片 JSON 格式", + Placeholder: "Leave empty to use the built-in interactive card", + Description: "If specified, this must be valid Feishu card JSON", }, }, }) @@ -140,32 +140,32 @@ func init() { // Register Telegram channel RegisterChannelDefinition(Definition{ Type: channelTelegram, - Name: "Telegram 机器人", - Description: "配置 Telegram 机器人推送消息。", + Name: "Telegram bot", + Description: "Configure Telegram bot notifications.", Fields: []Field{ { Key: KeyURL, - Label: "API 基础地址 (可选)", + Label: "API base URL (optional)", Type: TypeText, Required: false, Placeholder: "https://api.telegram.org", - Description: "接口请求的 HTTPS 基础地址,留空默认为 https://api.telegram.org", + Description: "HTTPS API base URL; defaults to https://api.telegram.org", }, { Key: KeyToken, - Label: "机器人 Token (Bot Token)", + Label: "Bot token", Type: TypePassword, Required: true, - Placeholder: "在此输入 Telegram 机器人的 Bot Token", - Description: "通过 BotFather 申请到的机器人 Access Token", + Placeholder: "Enter the Telegram bot token", + Description: "Bot access token issued by BotFather", }, { Key: KeyOther, - Label: "默认会话 ID (Chat ID) (可选)", + Label: "Default chat ID (optional)", Type: TypeText, Required: false, - Placeholder: "例如 -100123456789 或 @channel_name", - Description: "默认的消息接收 Chat ID。如果通知事件中未配置 targets,将推送到此 ID", + Placeholder: "For example -100123456789 or @channel_name", + Description: "Default recipient chat ID, used when the notification event has no targets", }, }, }) @@ -173,8 +173,8 @@ func init() { // Register Email channel RegisterChannelDefinition(Definition{ Type: channelEmail, - Name: "邮件推送通道", - Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。", + Name: "Email notification channel", + Description: "Email notifications use the global SMTP settings; no server configuration is required here.", Fields: []Field{}, }) } diff --git a/internal/apps/admin/push/custom_events/admin_login.go b/internal/apps/admin/push/custom_events/admin_login.go index 5fb25226..1e951e59 100644 --- a/internal/apps/admin/push/custom_events/admin_login.go +++ b/internal/apps/admin/push/custom_events/admin_login.go @@ -15,13 +15,13 @@ import ( // AdminLogin is the metadata definition for the admin login event. var AdminLogin = push.EventMetadata{ Key: "admin_login", - Name: "管理员登录", + Name: "Administrator login", DefaultTemplate: push.NotificationMessage{ - Title: "管理员登录提醒", - Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。", + Title: "Administrator login notification", + Content: "Administrator {{user.username}} signed in at {{time}} from IP {{ip}}.", Level: "INFO", }, - Description: "当管理员成功登录系统时触发此通知", + Description: "Triggered when an administrator signs in successfully", } func handleAdminLogin(ctx context.Context, event listener.AdminLoggedIn) { diff --git a/internal/apps/admin/push/custom_events/admin_login_test.go b/internal/apps/admin/push/custom_events/admin_login_test.go index 9ab14e8c..3550f8f6 100644 --- a/internal/apps/admin/push/custom_events/admin_login_test.go +++ b/internal/apps/admin/push/custom_events/admin_login_test.go @@ -138,7 +138,7 @@ func TestAdminLoginPushIntegration(t *testing.T) { assert.Equal(t, AdminLogin.Key, payload.EventKey) assert.Equal(t, "ops_team", payload.Target) - assert.Equal(t, "管理员登录提醒", payload.Body.Title) + assert.Equal(t, "Administrator login notification", payload.Body.Title) assert.Contains(t, payload.Body.Content, "super_admin") assert.Contains(t, payload.Body.Content, "203.0.113.42") }) diff --git a/internal/apps/admin/push/i18n.go b/internal/apps/admin/push/i18n.go new file mode 100644 index 00000000..1f5e6921 --- /dev/null +++ b/internal/apps/admin/push/i18n.go @@ -0,0 +1,36 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "github.com/Rain-kl/Wavelet/internal/infra/task" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/i18n" +) + +func localizeEventMetadata(locale string, meta EventMetadata) EventMetadata { + meta.Name = i18n.Translate(locale, meta.Name) + meta.Description = i18n.Translate(locale, meta.Description) + meta.DefaultTemplate.Title = i18n.Translate(locale, meta.DefaultTemplate.Title) + meta.DefaultTemplate.Content = i18n.Translate(locale, meta.DefaultTemplate.Content) + return meta +} + +func localizePushEvent(locale string, event model.PushEvent) model.PushEvent { + if meta, found := findBuiltInEvent(event.EventKey); found { + if event.Name == meta.Name || i18n.Translate("en", event.Name) == meta.Name { + event.Name = i18n.Translate(locale, meta.Name) + } + } else if meta := task.GetTaskMetaByAsynqTask(event.TaskType); meta != nil { + prefix := "Task completed: " + english := prefix + meta.Name + chinese := i18n.Translate("zh-CN", prefix) + i18n.Translate("zh-CN", meta.Name) + if event.Name == english || event.Name == chinese { + event.Name = i18n.Translate(locale, prefix) + i18n.Translate(locale, meta.Name) + } + } + // Templates and targets are administrator-owned data, even when they happen + // to match a built-in message. Never rewrite them during presentation. + return event +} diff --git a/internal/apps/admin/push/i18n_test.go b/internal/apps/admin/push/i18n_test.go new file mode 100644 index 00000000..50f9e939 --- /dev/null +++ b/internal/apps/admin/push/i18n_test.go @@ -0,0 +1,23 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "testing" + + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestLocalizeEventMetadataPreservesTemplatesAndRegistry(t *testing.T) { + source := EventMetadata{Name: "Administrator login", DefaultTemplate: NotificationMessage{Title: "Administrator login notification"}} + localized := localizeEventMetadata("zh-CN", source) + assert.Equal(t, "管理员登录", localized.Name) + assert.Equal(t, "管理员登录提醒", localized.DefaultTemplate.Title) + assert.Equal(t, "Administrator login", source.Name) + assert.Equal(t, "Administrator login notification", source.DefaultTemplate.Title) + + event := model.PushEvent{Name: "自定义事件", Template: `{"title":"管理员登录提醒"}`, Targets: []string{"系统"}} + assert.Equal(t, event, localizePushEvent("en", event)) +} diff --git a/internal/apps/admin/push/logics.go b/internal/apps/admin/push/logics.go index 713797e9..6a519d6b 100644 --- a/internal/apps/admin/push/logics.go +++ b/internal/apps/admin/push/logics.go @@ -24,6 +24,10 @@ type smtpConfig struct { Password string } +// This persisted recipient alias predates localization. Display translations +// must never expand the set of reserved routing identifiers. +const legacySystemTarget = "\u7cfb\u7edf" + func loadSMTPConfig(ctx context.Context) smtpConfig { host, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPHost) port, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPort) @@ -278,7 +282,7 @@ func recordPushHistory(ctx context.Context, req SendPayload, status, errMsg stri content := req.Body.Content level := req.Body.Level if title == "" { - title = "系统通知" + title = "System notification" } if level == "" { level = defaultLevelInfo @@ -331,7 +335,7 @@ func resolveTargetUser(ctx context.Context, resolved string, _ string) (model.Us } func resolveSystemTarget(ctx context.Context, resolved string, channel string) (string, bool) { - if resolved != "系统" && resolved != "system" && resolved != "0" { + if resolved != legacySystemTarget && resolved != "system" && resolved != "0" { return "", false } adminUser, err := repository.GetFirstAdminUser(ctx) @@ -383,10 +387,10 @@ func getEventInfo(req CreateEventRequest) (string, string, []byte, error) { return "", "", nil, errors.New("unsupported task type") } eventKey := "task_completed:" + req.TaskType - eventName := "任务完成: " + meta.Name + eventName := "Task completed: " + meta.Name defaultTemplate := NotificationMessage{ - Title: "任务完成: " + meta.Name, - Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。", + Title: "Task completed: " + meta.Name, + Content: "Asynchronous task {{task_name}} (ID: {{task_id}}) completed. Status: {{task_status}}; duration: {{task_duration}} ms.", Level: defaultLevelInfo, } defaultTemplateBytes, err := json.Marshal(defaultTemplate) diff --git a/internal/apps/admin/push/push_test.go b/internal/apps/admin/push/push_test.go index 0a30a855..d0235766 100644 --- a/internal/apps/admin/push/push_test.go +++ b/internal/apps/admin/push/push_test.go @@ -312,7 +312,7 @@ func TestPushHandler(t *testing.T) { t.Run("execute task successfully", func(t *testing.T) { res, execErr := handler.Execute(context.Background(), payloadBytes) require.NoError(t, execErr) - assert.Contains(t, res.Message, "推送成功") + assert.Contains(t, res.Message, "Notification sent") // Verify mock pusher received flattened variables mPusher.mu.Lock() @@ -553,6 +553,24 @@ func TestResolveTarget(t *testing.T) { ctx := context.Background() + for index, username := range []string{"System", "Система"} { + t.Run("translated system label is an ordinary username/"+username, func(t *testing.T) { + user := model.User{ + ID: uint64(20000 + index), Username: username, + Email: "ordinary_" + strconv.Itoa(index) + "@example.invalid", + } + if err := dbConn.Create(&user).Error; err != nil { + t.Fatalf("create user %q: %v", username, err) + } + for _, target := range []string{username, "user.username"} { + body := map[string]any{"user.username": username} + if got := resolveTarget(ctx, target, body, channelEmail); got != user.Email { + t.Errorf("resolveTarget(%q, username=%q, email) = %q, want %q", target, username, got, user.Email) + } + } + }) + } + t.Run("dynamic user.id resolved and converted for email channel", func(t *testing.T) { res := resolveTarget(ctx, "user.id", flatBody, "email") assert.Equal(t, "target@test.com", res) diff --git a/internal/apps/admin/push/routers.go b/internal/apps/admin/push/routers.go index 7f937c13..0a59c158 100644 --- a/internal/apps/admin/push/routers.go +++ b/internal/apps/admin/push/routers.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strconv" "github.com/Rain-kl/Wavelet/internal/model" @@ -54,6 +55,9 @@ func ListEvents(c *gin.Context) { response.AbortInternal(c, err.Error()) return } + for index := range events { + events[index] = localizePushEvent(response.Locale(c), events[index]) + } c.JSON(http.StatusOK, response.OK(events)) } @@ -85,7 +89,11 @@ func findBuiltInEvent(key string) (EventMetadata, bool) { // @Success 200 {object} response.Any{data=[]EventMetadata} "内置通知事件列表" // @Router /api/v1/admin/push/events/builtin [get] func ListBuiltInEvents(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(BuiltInEvents)) + metas := slices.Clone(BuiltInEvents) + for index := range metas { + metas[index] = localizeEventMetadata(response.Locale(c), metas[index]) + } + c.JSON(http.StatusOK, response.OK(metas)) } // CreateEvent 创建通知事件 @@ -110,7 +118,7 @@ func CreateEvent(c *gin.Context) { response.AbortBadRequest(c, err.Error()) return } - c.JSON(http.StatusOK, response.OK(event)) + c.JSON(http.StatusOK, response.OK(localizePushEvent(response.Locale(c), event))) } // DeleteEvent 删除通知事件配置 @@ -280,8 +288,8 @@ func TestPush(c *gin.Context) { applySMTPFallbackToPushConfig(c.Request.Context(), &req.Config) testBody := map[string]any{ - keyTitle: "测试通道推送", - keyContent: "当您收到这条消息,说明当前渠道连通性测试通过。", + keyTitle: "Notification channel test", + keyContent: "Receiving this message confirms that the notification channel connectivity test succeeded.", keyLevel: defaultLevelInfo, } if _, err := pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil); err != nil { diff --git a/internal/apps/admin/push/task_listener.go b/internal/apps/admin/push/task_listener.go index ec119710..b37956d9 100644 --- a/internal/apps/admin/push/task_listener.go +++ b/internal/apps/admin/push/task_listener.go @@ -67,7 +67,7 @@ func handleTaskCompleted(ctx context.Context, execution *model.TaskExecution, re meta := EventMetadata{ Key: event.EventKey, Name: event.Name, - Description: "异步任务执行完毕触发的自动通知", + Description: "Automatic notification triggered when an asynchronous task finishes", } DefaultTrigger.Trigger(ctx, meta, body) } diff --git a/internal/apps/admin/push/tasks.go b/internal/apps/admin/push/tasks.go index de310e87..51552558 100644 --- a/internal/apps/admin/push/tasks.go +++ b/internal/apps/admin/push/tasks.go @@ -25,8 +25,8 @@ const ( var SendNotificationMeta = task.TaskMeta{ Type: TaskTypeSendNotification, AsynqTask: SendNotificationTask, - Name: "推送通知", - Description: "异步执行系统通知的多渠道派发与推送", + Name: "Send notification", + Description: "Dispatch system notifications asynchronously across multiple channels", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -34,14 +34,14 @@ var SendNotificationMeta = task.TaskMeta{ Params: []task.TaskParam{ { Name: "event_key", - Label: "事件标识", + Label: "Event key", Type: "string", Required: true, Placeholder: "admin_login", }, { Name: "target", - Label: "目标接收者", + Label: "Recipient", Type: "string", Required: false, }, @@ -75,16 +75,16 @@ func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) { func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { var req SendPayload if err := json.Unmarshal(payload, &req); err != nil { - task.AppendLog(ctx, "解析推送参数失败: %v", err) + task.AppendLog(ctx, "Failed to parse notification parameters: %v", err) return nil, fmt.Errorf("parse payload failed: %w", err) } - task.AppendLog(ctx, "开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target) + task.AppendLog(ctx, "Starting notification: event = %s, channel = %s, recipient = %s", req.EventKey, req.Config.Channel, req.Target) pusher, err := push.GetPusher(req.Config.Channel) if err != nil { errWrap := fmt.Errorf("get pusher failed: %w", err) - task.AppendLog(ctx, "推送失败: %v", errWrap) + task.AppendLog(ctx, "Notification failed: %v", errWrap) if task.IsFinalAttempt(ctx) { h.recordHistory(ctx, req, "failed", errWrap.Error()) } @@ -99,9 +99,9 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskRe content := req.Body.Content if err != nil { - task.AppendLog(ctx, "消息推送失败 (标题: %s): %v", title, err) + task.AppendLog(ctx, "Failed to send notification (title: %s): %v", title, err) if upstreamResp != "" { - task.AppendLog(ctx, "上游返回: %s", upstreamResp) + task.AppendLog(ctx, "Upstream response: %s", upstreamResp) } if task.IsFinalAttempt(ctx) { h.recordHistory(ctx, req, "failed", err.Error()) @@ -109,19 +109,19 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskRe return nil, fmt.Errorf("pusher.Send failed: %w", err) } - task.AppendLog(ctx, "消息推送成功 (标题: %s, 内容摘要: %s)", title, content) + task.AppendLog(ctx, "Notification sent (title: %s, content summary: %s)", title, content) if upstreamResp != "" { - task.AppendLog(ctx, "上游返回: %s", upstreamResp) + task.AppendLog(ctx, "Upstream response: %s", upstreamResp) } h.recordHistory(ctx, req, "success", "") return &task.TaskResult{ - Message: fmt.Sprintf("推送成功: [%s] -> %s", req.Config.Channel, req.Target), + Message: fmt.Sprintf("Notification sent: [%s] -> %s", req.Config.Channel, req.Target), }, nil } func (h *PushHandler) recordHistory(ctx context.Context, req SendPayload, status string, errMsg string) { if dbErr := recordPushHistory(ctx, req, status, errMsg); dbErr != nil { - task.AppendLog(ctx, "写入推送历史审计记录失败: %v", dbErr) + task.AppendLog(ctx, "Failed to write notification audit history: %v", dbErr) } } diff --git a/internal/apps/admin/status/clickhouse.go b/internal/apps/admin/status/clickhouse.go index 3785466c..d1d14b57 100644 --- a/internal/apps/admin/status/clickhouse.go +++ b/internal/apps/admin/status/clickhouse.go @@ -55,16 +55,16 @@ func GetLogDatabaseStatus(c *gin.Context) { ctx := c.Request.Context() store, err := logstore.Active(ctx) if err != nil { - logger.ErrorF(ctx, "获取日志存储实例失败: %v", err) - response.AbortInternal(c, "日志存储初始化失败") + logger.ErrorF(ctx, "Failed to get log storage instance: %v", err) + response.AbortInternal(c, "Failed to initialize log storage") return } // 分支判定复用同一 store 实例的 ActiveDatabase,避免与 Active 解析之间出现 TOCTOU。 activeDB, err := store.Status.ActiveDatabase(ctx) if err != nil { - logger.ErrorF(ctx, "获取日志库状态失败: %v", err) - response.AbortInternal(c, "获取日志库状态失败") + logger.ErrorF(ctx, "Failed to get log database status: %v", err) + response.AbortInternal(c, "Failed to get log database status") return } migration := "idle" @@ -86,7 +86,7 @@ func GetLogDatabaseStatus(c *gin.Context) { if activeDB == logDBNameClickHouse { stats, err := store.Status.ClickHouseOperationalStats(ctx) if err != nil { - logger.ErrorF(ctx, "获取 ClickHouse 运行指标失败: %v", err) + logger.ErrorF(ctx, "Failed to get ClickHouse runtime metrics: %v", err) } else { stats.BatchWriters = collectBatchWriterStats() out.ClickHouse = stats @@ -101,7 +101,7 @@ func retentionOr(ctx context.Context, key string) int { v, err := repository.GetIntByKey(ctx, key) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { - logger.ErrorF(ctx, "读取日志保留天数配置失败 key=%s: %v", key, err) + logger.ErrorF(ctx, "Failed to read log retention configuration key=%s: %v", key, err) } return defaultLogRetentionDays } diff --git a/internal/apps/admin/status/duration_test.go b/internal/apps/admin/status/duration_test.go new file mode 100644 index 00000000..93d5f27f --- /dev/null +++ b/internal/apps/admin/status/duration_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "testing" + "time" +) + +func TestFormatDuration(t *testing.T) { + for _, tc := range []struct { + duration time.Duration + want string + }{ + {duration: 0, want: "0 seconds"}, + {duration: time.Second, want: "1 second"}, + {duration: 61 * time.Second, want: "1 minute 1 second"}, + {duration: 26 * time.Hour, want: "1 day 2 hours"}, + {duration: 48*time.Hour + 2*time.Minute + 5*time.Second, want: "2 days 2 minutes 5 seconds"}, + } { + if got := formatDuration(tc.duration); got != tc.want { + t.Errorf("formatDuration(%v) = %q, want %q", tc.duration, got, tc.want) + } + } +} diff --git a/internal/apps/admin/status/routers.go b/internal/apps/admin/status/routers.go index a6f47f67..074e4f9e 100644 --- a/internal/apps/admin/status/routers.go +++ b/internal/apps/admin/status/routers.go @@ -16,6 +16,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "time" "github.com/Rain-kl/Wavelet/internal/infra/config" @@ -118,20 +119,20 @@ func formatDuration(d time.Duration) string { minutes := int(d.Minutes()) % minutesInHour seconds := int(d.Seconds()) % secondsInMinute - var res string - if days > 0 { - res += fmt.Sprintf("%d天", days) + var parts []string + for _, part := range []struct { + value int + unit string + }{{days, "day"}, {hours, "hour"}, {minutes, "minute"}, {seconds, "second"}} { + if part.value > 0 || (part.unit == "second" && len(parts) == 0) { + unit := part.unit + if part.value != 1 { + unit += "s" + } + parts = append(parts, fmt.Sprintf("%d %s", part.value, unit)) + } } - if hours > 0 { - res += fmt.Sprintf("%d小时", hours) - } - if minutes > 0 { - res += fmt.Sprintf("%d分钟", minutes) - } - if seconds > 0 || res == "" { - res += fmt.Sprintf("%d秒钟", seconds) - } - return res + return strings.Join(parts, " ") } // GetSystemStatus 获取系统状态信息 @@ -156,9 +157,9 @@ func GetSystemStatus(c *gin.Context) { case m.LastGC > 0 && m.LastGC <= math.MaxInt64: lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC)))) case m.LastGC > 0: - lastGCTime = "未知" + lastGCTime = "Unknown" default: - lastGCTime = "无" + lastGCTime = "None" } var lastPause string @@ -292,7 +293,7 @@ func exportSQLite(c *gin.Context) { dir, err := os.MkdirTemp("", "openflare-db-export-*") if err != nil { logger.ErrorF(c.Request.Context(), "[db-export] create temporary directory failed: %v", err) - response.AbortInternal(c, "无法创建数据库导出文件") + response.AbortInternal(c, "Unable to create database export file") return } defer func() { @@ -303,13 +304,13 @@ func exportSQLite(c *gin.Context) { path := filepath.Join(dir, "openflare.db") if err := repository.ExportSQLiteSnapshot(c.Request.Context(), path); err != nil { logger.ErrorF(c.Request.Context(), "[db-export] create SQLite snapshot failed: %v", err) - response.AbortInternal(c, "无法导出数据库") + response.AbortInternal(c, "Unable to export database") return } f, err := os.Open(path) //nolint:gosec // path is generated in a private temporary directory, not from user input if err != nil { - response.AbortInternal(c, "无法打开数据库文件: "+err.Error()) + response.AbortInternal(c, "Unable to open database file: "+err.Error()) return } defer func() { @@ -320,7 +321,7 @@ func exportSQLite(c *gin.Context) { fi, err := f.Stat() if err != nil { - response.AbortInternal(c, "无法读取数据库文件信息: "+err.Error()) + response.AbortInternal(c, "Unable to read database file information: "+err.Error()) return } @@ -338,7 +339,7 @@ func exportPostgres(c *gin.Context) { // 检查 pg_dump 是否可用 pgDumpPath, err := exec.LookPath("pg_dump") if err != nil { - response.AbortInternal(c, "pg_dump 不可用,请确保服务器已安装 PostgreSQL 客户端工具") + response.AbortInternal(c, "pg_dump is unavailable; install the PostgreSQL client tools on the server") return } diff --git a/internal/apps/admin/system_config/errs.go b/internal/apps/admin/system_config/errs.go index 0ec1df84..8c6ab7d1 100644 --- a/internal/apps/admin/system_config/errs.go +++ b/internal/apps/admin/system_config/errs.go @@ -7,9 +7,9 @@ package system_config // 系统配置错误消息常量 const ( - SystemConfigNotFound = "系统配置不存在" - ConfigKeyRequired = "配置键不能为空" - ConfigValueRequired = "配置值不能为空" - ConfigKeyExists = "配置键已存在" - StorageDriverSwitchRequiresMigration = "存在存量文件,请通过存储迁移任务切换存储引擎" + SystemConfigNotFound = "System configuration was not found" + ConfigKeyRequired = "Configuration key must not be empty" + ConfigValueRequired = "Configuration value must not be empty" + ConfigKeyExists = "configuration key already exists" + StorageDriverSwitchRequiresMigration = "existing files were found; use the storage migration task to switch storage backends" ) diff --git a/internal/apps/admin/system_config/logics.go b/internal/apps/admin/system_config/logics.go index 31240bee..05b4c77e 100644 --- a/internal/apps/admin/system_config/logics.go +++ b/internal/apps/admin/system_config/logics.go @@ -43,7 +43,7 @@ func createSystemConfig(ctx context.Context, req CreateSystemConfigRequest) erro invalidateSystemConfigCaches(ctx, req.Key) if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { - logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) + logger.WarnF(ctx, "Failed to clear public configuration list cache: %v", err) } return nil } @@ -71,7 +71,7 @@ func updateSystemConfig(ctx context.Context, key string, req UpdateSystemConfigR validatedVal, err := validateAndMergeStorageConfig(ctx, req.Value, config.Value) if err != nil { - return err + return &storageConfigValidationError{err} } req.Value = validatedVal } @@ -123,9 +123,9 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate( if err := repository.MarkFailedTaskExecutionsSucceededTx( tx, "storage:migrate", - "存储配置直接更新,故障迁移任务自动标记为已解决", + "Storage configuration updated directly; failed migration task marked as resolved", time.Now(), ); err != nil { - logger.ErrorF(ctx, "自动更新迁移任务状态失败: %v", err) + logger.ErrorF(ctx, "Failed to automatically update migration task status: %v", err) } } diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index 9d6e6830..38f1923a 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "net/http" - "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -28,7 +27,7 @@ import ( const maskedConfigValue = "******" // protectedConfigKeyMessage 命中受保护 key 时返回给管理员的业务错误文案。 -const protectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改" +const protectedConfigKeyMessage = "this configuration is managed by system tasks and cannot be changed manually" // protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。 var protectedConfigKeys = map[string]bool{ @@ -198,7 +197,7 @@ func UpdateSystemConfig(c *gin.Context) { func invalidateSystemConfigCaches(ctx context.Context, key string) { if err := repository.InvalidateSystemConfigCache(ctx, key); err != nil { - logger.WarnF(ctx, "清理系统配置缓存失败: %v", err) + logger.WarnF(ctx, "Failed to clear system configuration cache: %v", err) } if cap.IsRuntimeConfigKey(key) { cap.InvalidateRuntimeSettings() @@ -220,7 +219,7 @@ func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) { } if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { - logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) + logger.WarnF(ctx, "Failed to clear public configuration list cache: %v", err) } } @@ -289,15 +288,13 @@ func TestSMTP(c *gin.Context) { c.JSON(http.StatusOK, response.OK(resp)) } +type storageConfigValidationError struct{ error } + +func (e *storageConfigValidationError) Unwrap() error { return e.error } + func isStorageConfigValidationError(err error) bool { - msg := err.Error() - return msg == StorageDriverSwitchRequiresMigration || - strings.HasPrefix(msg, "解析") || - strings.HasPrefix(msg, "验证") || - strings.HasPrefix(msg, "初始化测试") || - strings.HasPrefix(msg, "存储连通性") || - strings.HasPrefix(msg, "序列化") || - strings.HasPrefix(msg, "检查存量文件") + var validationErr *storageConfigValidationError + return errors.As(err, &validationErr) } func maskSensitiveConfig(key, value string) string { @@ -324,12 +321,12 @@ func maskSensitiveConfig(key, value string) string { func validateAndMergeStorageConfig(ctx context.Context, value string, currentConfig string) (string, error) { var currentCfg objectstore.Config if err := json.Unmarshal([]byte(currentConfig), ¤tCfg); err != nil { - return "", fmt.Errorf("解析当前存储配置失败: %w", err) + return "", fmt.Errorf("failed to parse current storage configuration: %w", err) } var newCfg objectstore.Config if err := json.Unmarshal([]byte(value), &newCfg); err != nil { - return "", fmt.Errorf("解析目标存储配置失败: %w", err) + return "", fmt.Errorf("failed to parse target storage configuration: %w", err) } // 合并被掩码屏蔽的敏感信息,获取完整的真实配置 @@ -341,7 +338,7 @@ func validateAndMergeStorageConfig(ctx context.Context, value string, currentCon // 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符 unmaskedVal, err := json.Marshal(targetCfg) if err != nil { - return "", fmt.Errorf("序列化存储配置失败: %w", err) + return "", fmt.Errorf("failed to serialize storage configuration: %w", err) } return string(unmaskedVal), nil @@ -351,13 +348,13 @@ func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, target if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver { uploadCount, err := repository.CountActiveUploads(ctx) if err != nil { - return fmt.Errorf("检查存量文件失败: %w", err) + return fmt.Errorf("failed to check existing files: %w", err) } if uploadCount > 0 { return errors.New(StorageDriverSwitchRequiresMigration) } if err := validateDriverConfig(targetCfg, newCfg.Driver); err != nil { - return fmt.Errorf("验证目标存储配置参数失败: %w", err) + return fmt.Errorf("failed to validate target storage parameters: %w", err) } pendingCfg := targetCfg pendingCfg.Driver = newCfg.Driver @@ -365,7 +362,7 @@ func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, target } if err := objectstore.ValidateConfig(targetCfg); err != nil { - return fmt.Errorf("验证存储配置参数失败: %w", err) + return fmt.Errorf("failed to validate storage parameters: %w", err) } return testStorageBackend(ctx, targetCfg, targetCfg.Driver) } @@ -378,10 +375,10 @@ func validateDriverConfig(cfg objectstore.Config, driver objectstore.Driver) err func testStorageBackend(ctx context.Context, cfg objectstore.Config, driver objectstore.Driver) error { testBackend, err := objectstore.NewBackend(ctx, cfg, driver) if err != nil { - return fmt.Errorf("初始化测试存储实例失败: %w", err) + return fmt.Errorf("failed to initialize test storage instance: %w", err) } if err := testBackend.Test(ctx); err != nil { - return fmt.Errorf("存储连通性测试失败: %w", err) + return fmt.Errorf("storage connectivity test failed: %w", err) } return nil } diff --git a/internal/apps/admin/task/errs.go b/internal/apps/admin/task/errs.go index e45edf79..1be3e7d4 100644 --- a/internal/apps/admin/task/errs.go +++ b/internal/apps/admin/task/errs.go @@ -7,17 +7,17 @@ package task // 任务管理相关错误消息 const ( - InvalidTaskType = "无效的任务类型" - InvalidTimeRange = "无效的时间范围" - TaskDispatchFailed = "任务下发失败" - UserIDRequired = "用户ID必填" - TaskNotFound = "任务执行记录不存在" - TaskNotRetryable = "该任务不支持重试" - TaskNotFailed = "只有失败的任务才能重试" - TaskMaxRetryExceeded = "已达到最大重试次数" - TaskRetryFailed = "任务重试失败" - InvalidCronExpression = "无效的 Cron 表达式" - ScheduleNotFound = "定时任务不存在" - ScheduleSaveFailed = "保存定时任务失败" - ScheduleDeleteFailed = "删除定时任务失败" + InvalidTaskType = "Invalid task type" + InvalidTimeRange = "Invalid time range" + TaskDispatchFailed = "Failed to dispatch task" + UserIDRequired = "User ID is required" + TaskNotFound = "Task execution was not found" + TaskNotRetryable = "this task cannot be retried" + TaskNotFailed = "Only failed tasks can be retried" + TaskMaxRetryExceeded = "Maximum retry count reached" + TaskRetryFailed = "Failed to retry task" + InvalidCronExpression = "Invalid Cron expression" + ScheduleNotFound = "Schedule was not found" + ScheduleSaveFailed = "Failed to save schedule" + ScheduleDeleteFailed = "Failed to delete schedule" ) diff --git a/internal/apps/admin/task/i18n.go b/internal/apps/admin/task/i18n.go new file mode 100644 index 00000000..4d5bc3c9 --- /dev/null +++ b/internal/apps/admin/task/i18n.go @@ -0,0 +1,53 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "slices" + + "github.com/Rain-kl/Wavelet/internal/infra/task" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/i18n" +) + +// Localize copies, never the shared registry or persistent task records. +func localizeTaskMeta(locale string, meta task.TaskMeta) task.TaskMeta { + meta.Name = i18n.Translate(locale, meta.Name) + meta.Description = i18n.Translate(locale, meta.Description) + meta.Params = slices.Clone(meta.Params) + for index := range meta.Params { + param := &meta.Params[index] + param.Label = i18n.Translate(locale, param.Label) + param.Placeholder = i18n.Translate(locale, param.Placeholder) + param.Description = i18n.Translate(locale, param.Description) + } + return meta +} + +func localizeTaskExecution(locale string, execution model.TaskExecution) model.TaskExecution { + execution.TaskName = i18n.Translate(locale, execution.TaskName) + execution.Log = i18n.TranslateLog(locale, execution.Log) + execution.ErrorMessage = i18n.Translate(locale, execution.ErrorMessage) + execution.Result = i18n.Translate(locale, execution.Result) + return execution +} + +func localizeTaskExecutions(locale string, executions []model.TaskExecution) []model.TaskExecution { + localized := slices.Clone(executions) + for index := range localized { + localized[index] = localizeTaskExecution(locale, localized[index]) + } + return localized +} + +func localizeScheduleName(locale, name string, meta *task.TaskMeta) string { + // Only the built-in default name is system text. Custom schedule names are data. + if meta != nil && meta.Type == "system_cleanup" && i18n.Translate("en", name) == "Scheduled system cleanup" { + return i18n.Translate(locale, "Scheduled system cleanup") + } + if meta != nil && i18n.Translate("en", name) == meta.Name { + return i18n.Translate(locale, meta.Name) + } + return name +} diff --git a/internal/apps/admin/task/i18n_test.go b/internal/apps/admin/task/i18n_test.go new file mode 100644 index 00000000..04940476 --- /dev/null +++ b/internal/apps/admin/task/i18n_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "testing" + + "github.com/Rain-kl/Wavelet/internal/infra/task" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestLocalizeTaskMetaDoesNotMutateSharedParams(t *testing.T) { + source := task.TaskMeta{ + Name: "Switch log database", AsynqTask: "openflare:log_db_switch", + Params: []task.TaskParam{{Name: "target", Label: "Target log database", Placeholder: "postgres|sqlite|clickhouse"}}, + } + localized := localizeTaskMeta("zh-CN", source) + assert.Equal(t, "切换日志数据库", localized.Name) + assert.Equal(t, "目标日志库", localized.Params[0].Label) + assert.Equal(t, "openflare:log_db_switch", localized.AsynqTask) + assert.Equal(t, "target", localized.Params[0].Name) + assert.Equal(t, "Target log database", source.Params[0].Label) +} + +func TestLocalizeTaskExecutionsPreservesSourceAndPayload(t *testing.T) { + source := []model.TaskExecution{{ + TaskName: "OpenFlare SSL 自动续期", Log: "[12:34:56] 开始扫描待续期证书\n", + Payload: `{"name":"开始扫描待续期证书"}`, TriggeredBy: "用户甲", + }} + localized := localizeTaskExecutions("en", source) + assert.Equal(t, "OpenFlare automatic SSL renewal", localized[0].TaskName) + assert.Equal(t, "[12:34:56] Scanning certificates due for renewal\n", localized[0].Log) + assert.Equal(t, source[0].Payload, localized[0].Payload) + assert.Equal(t, source[0].TriggeredBy, localized[0].TriggeredBy) + assert.Equal(t, "OpenFlare SSL 自动续期", source[0].TaskName) +} + +func TestLocalizeScheduleNamePreservesCustomNames(t *testing.T) { + meta := &task.TaskMeta{Type: "system_cleanup", Name: "System cleanup"} + assert.Equal(t, "Scheduled system cleanup", localizeScheduleName("en", "系统定期垃圾清理", meta)) + assert.Equal(t, "系统定期垃圾清理", localizeScheduleName("zh-CN", "Scheduled system cleanup", meta)) + assert.Equal(t, "自定义每周清理", localizeScheduleName("en", "自定义每周清理", meta)) +} diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index 8f6bf135..f3b5738f 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -5,6 +5,7 @@ package task import ( + "errors" "fmt" "net/http" "strconv" @@ -35,7 +36,11 @@ import ( // @Failure 403 {object} response.Any "无管理员权限" // @Router /api/v1/admin/tasks/types [get] func ListTaskTypes(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(task.GetDispatchableTasks())) + metas := task.GetDispatchableTasks() + for index := range metas { + metas[index] = localizeTaskMeta(response.Locale(c), metas[index]) + } + c.JSON(http.StatusOK, response.OK(metas)) } // DispatchTaskRequest 下发任务请求 @@ -130,7 +135,7 @@ func ListTaskExecutions(c *gin.Context) { } c.JSON(http.StatusOK, response.OK(gin.H{ - "items": executions, + "items": localizeTaskExecutions(response.Locale(c), executions), "total": total, "page": req.Page, "page_size": req.PageSize, @@ -163,7 +168,8 @@ func GetTaskExecution(c *gin.Context) { return } - c.JSON(http.StatusOK, response.OK(execution)) + localized := localizeTaskExecution(response.Locale(c), *execution) + c.JSON(http.StatusOK, response.OK(localized)) } // RetryTask 重试失败的任务 @@ -190,10 +196,11 @@ func RetryTask(c *gin.Context) { newTaskID, err := task.RetryTask(c.Request.Context(), id) if err != nil { errMsg := err.Error() + var retryErr *task.RetryError switch { - case strings.Contains(errMsg, "不存在"): + case errors.As(err, &retryErr) && retryErr.NotFound: response.AbortNotFound(c, errMsg) - case strings.Contains(errMsg, "只有失败的任务") || strings.Contains(errMsg, "不支持重试") || strings.Contains(errMsg, "已达到最大重试"): + case retryErr != nil: response.AbortBadRequest(c, errMsg) default: response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskRetryFailed, err)) @@ -226,6 +233,7 @@ func ListSchedules(c *gin.Context) { if meta != nil && meta.InternalOnly { continue } + schedule.Name = localizeScheduleName(response.Locale(c), schedule.Name, meta) visible = append(visible, schedule) } c.JSON(http.StatusOK, response.OK(visible)) @@ -300,7 +308,7 @@ func CreateSchedule(c *gin.Context) { // 触发调度服务重载 if err := scheduler.ReloadScheduler(); err != nil { - logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err) + logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err) } c.JSON(http.StatusOK, response.OK(schedule)) @@ -334,7 +342,7 @@ type UpdateScheduleRequest struct { func UpdateSchedule(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - response.AbortBadRequest(c, "无效的定时任务ID") + response.AbortBadRequest(c, "Invalid schedule ID") return } @@ -392,7 +400,7 @@ func UpdateSchedule(c *gin.Context) { // 触发调度服务重载 if err := scheduler.ReloadScheduler(); err != nil { - logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err) + logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err) } c.JSON(http.StatusOK, response.OK(schedule)) @@ -423,7 +431,7 @@ func getAdminTaskMeta(taskType string) *task.TaskMeta { func DeleteSchedule(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - response.AbortBadRequest(c, "无效的定时任务ID") + response.AbortBadRequest(c, "Invalid schedule ID") return } schedule, err := repository.GetScheduleByID(c.Request.Context(), id) @@ -443,7 +451,7 @@ func DeleteSchedule(c *gin.Context) { // 触发调度服务重载 if err := scheduler.ReloadScheduler(); err != nil { - logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err) + logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err) } c.JSON(http.StatusOK, response.OKNil()) diff --git a/internal/apps/admin/task/routers_test.go b/internal/apps/admin/task/routers_test.go index 8ce7d0e3..40929c6e 100644 --- a/internal/apps/admin/task/routers_test.go +++ b/internal/apps/admin/task/routers_test.go @@ -410,7 +410,7 @@ func TestDispatchTask(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) var resp response.Any json.Unmarshal(w.Body.Bytes(), &resp) - assert.Contains(t, resp.ErrorMsg, "无效的 JSON 格式") + assert.Contains(t, resp.ErrorMsg, "invalid JSON format") }) t.Run("dispatch send_email task failure with missing fields", func(t *testing.T) { @@ -427,7 +427,7 @@ func TestDispatchTask(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) var resp response.Any json.Unmarshal(w.Body.Bytes(), &resp) - assert.Contains(t, resp.ErrorMsg, "不能为空") + assert.Contains(t, resp.ErrorMsg, "must not be empty") }) t.Run("dispatch invalid task type failure", func(t *testing.T) { diff --git a/internal/apps/admin/template/errs.go b/internal/apps/admin/template/errs.go index d8dda622..1dd103a5 100644 --- a/internal/apps/admin/template/errs.go +++ b/internal/apps/admin/template/errs.go @@ -6,11 +6,11 @@ package template // 模板管理相关错误消息 const ( - TemplateNotFound = "模板不存在" - TemplateKeyRequired = "模板标识符不能为空" - TemplateNameRequired = "模板名称不能为空" - TemplateContentRequired = "模板内容不能为空" - TemplateKeyExists = "模板标识符已存在" - SystemTemplateCannotDelete = "系统预置模板不可删除" - SystemTemplateCannotModifyKey = "系统预置模板不可修改标识符" + TemplateNotFound = "Template was not found" + TemplateKeyRequired = "Template identifier must not be empty" + TemplateNameRequired = "Template name must not be empty" + TemplateContentRequired = "Template content must not be empty" + TemplateKeyExists = "template identifier already exists" + SystemTemplateCannotDelete = "built-in templates cannot be deleted" + SystemTemplateCannotModifyKey = "Built-in template identifiers cannot be changed" ) diff --git a/internal/apps/admin/updater/errs.go b/internal/apps/admin/updater/errs.go index ecdbbecd..0825a220 100644 --- a/internal/apps/admin/updater/errs.go +++ b/internal/apps/admin/updater/errs.go @@ -5,13 +5,13 @@ package updater const ( - errInvalidRepository = "上游仓库地址无效" - errReleaseRequestFailed = "获取上游版本失败" - errReleaseResponseInvalid = "上游版本响应无效" - errNoCompatibleRelease = "未找到兼容的 Release" - errNoCompatibleAsset = "未找到当前系统对应的 Release 资产" - errDevelopmentBuild = "开发版本无法执行自动升级" - errAlreadyUpToDate = "当前已是最新版本" - errUpgradeAlreadyRunning = "已有升级任务正在执行" - errAutomaticUpgradeBlocked = "当前平台暂不支持自动替换二进制" + errInvalidRepository = "invalid upstream repository URL" + errReleaseRequestFailed = "Failed to fetch upstream version" + errReleaseResponseInvalid = "Invalid upstream version response" + errNoCompatibleRelease = "no compatible release was found" + errNoCompatibleAsset = "no release asset was found for the current platform" + errDevelopmentBuild = "development builds do not support automatic updates" + errAlreadyUpToDate = "already running the latest version" + errUpgradeAlreadyRunning = "an update is already in progress" + errAutomaticUpgradeBlocked = "automatic binary replacement is not supported on this platform" ) diff --git a/internal/apps/admin/updater/logics.go b/internal/apps/admin/updater/logics.go index 9f1a783f..ba4da626 100644 --- a/internal/apps/admin/updater/logics.go +++ b/internal/apps/admin/updater/logics.go @@ -265,42 +265,42 @@ func (m *manager) status(ctx context.Context) (Status, releaseAsset, error) { func downloadArchive(ctx context.Context, client releaseClient, asset releaseAsset, destination string) error { if asset.Size <= 0 || asset.Size > maxArchiveSize { - return fmt.Errorf("release 资产大小无效: %d", asset.Size) + return fmt.Errorf("invalid release asset size: %d", asset.Size) } logger.InfoF(ctx, "[Updater] Downloading release asset: %s", asset.Name) req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.BrowserDownloadURL, nil) if err != nil { - return fmt.Errorf("创建升级下载请求失败: %w", err) + return fmt.Errorf("failed to create update download request: %w", err) } req.Header.Set("User-Agent", "OpenFlare-Updater") resp, err := client.Do(req) if err != nil { - return fmt.Errorf("下载升级资产失败: %w", err) + return fmt.Errorf("failed to download update asset: %w", err) } defer func() { // The downloaded body has already been validated by size before use. _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("下载升级资产失败: HTTP %d", resp.StatusCode) + return fmt.Errorf("failed to download update asset: HTTP %d", resp.StatusCode) } file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, archiveFileMode) //nolint:gosec // destination is created inside the verified executable directory. if err != nil { - return fmt.Errorf("创建升级归档失败: %w", err) + return fmt.Errorf("failed to create update archive: %w", err) } written, err := io.Copy(file, io.LimitReader(resp.Body, maxArchiveSize+1)) if err != nil { _ = file.Close() - return fmt.Errorf("写入升级归档失败: %w", err) + return fmt.Errorf("failed to write update archive: %w", err) } if err := file.Close(); err != nil { - return fmt.Errorf("关闭升级归档失败: %w", err) + return fmt.Errorf("failed to close update archive: %w", err) } if written > maxArchiveSize || written != asset.Size { - return fmt.Errorf("升级归档大小不匹配: got %d, want %d", written, asset.Size) + return fmt.Errorf("update archive size mismatch: got %d, want %d", written, asset.Size) } logger.InfoF(ctx, "[Updater] Successfully downloaded release asset to %s", destination) return nil @@ -309,12 +309,12 @@ func downloadArchive(ctx context.Context, client releaseClient, asset releaseAss func safeArchivePath(destination, name string) (string, error) { cleanName := filepath.Clean(name) if filepath.IsAbs(cleanName) || cleanName == "." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("归档包含非法路径: %s", name) + return "", fmt.Errorf("archive contains an invalid path: %s", name) } target := filepath.Join(destination, cleanName) relative, err := filepath.Rel(destination, target) if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("归档路径越界: %s", name) + return "", fmt.Errorf("archive path escapes the destination: %s", name) } return target, nil } @@ -510,7 +510,7 @@ func extractTarGz(ctx context.Context, archivePath, destination, targetName stri return "", closeErr } if written > maxArchiveSize { - return "", errors.New("解压后的程序文件超过大小限制") + return "", errors.New("extracted executable exceeds the size limit") } logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target) return target, nil @@ -563,7 +563,7 @@ func extractZip(ctx context.Context, archivePath, destination, targetName string return "", outputCloseErr } if written > maxArchiveSize { - return "", errors.New("解压后的程序文件超过大小限制") + return "", errors.New("extracted executable exceeds the size limit") } logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target) return target, nil @@ -597,16 +597,16 @@ func (m *manager) prepareUpgrade(ctx context.Context) (string, string, error) { executable, err := os.Executable() if err != nil { - return "", "", fmt.Errorf("定位当前程序失败: %w", err) + return "", "", fmt.Errorf("failed to locate current executable: %w", err) } executable, err = filepath.EvalSymlinks(executable) if err != nil { - return "", "", fmt.Errorf("解析当前程序路径失败: %w", err) + return "", "", fmt.Errorf("failed to resolve current executable path: %w", err) } tempDir, err := os.MkdirTemp(filepath.Dir(executable), ".wavelet-update-*") if err != nil { - return "", "", fmt.Errorf("创建升级目录失败: %w", err) + return "", "", fmt.Errorf("failed to create update directory: %w", err) } archivePath := filepath.Join(tempDir, asset.Name) @@ -628,7 +628,7 @@ func (m *manager) prepareUpgrade(ctx context.Context) (string, string, error) { if err != nil { // Cleanup is best effort because the extraction error is the actionable failure. _ = os.RemoveAll(tempDir) - return "", "", fmt.Errorf("解压升级资产失败: %w", err) + return "", "", fmt.Errorf("failed to extract update asset: %w", err) } logger.InfoF(ctx, "[Updater] Staged binary successfully prepared: %s", stagedBinary) m.upgrading = true diff --git a/internal/apps/admin/updater/restart_unix.go b/internal/apps/admin/updater/restart_unix.go index 24d94c6c..897b3461 100644 --- a/internal/apps/admin/updater/restart_unix.go +++ b/internal/apps/admin/updater/restart_unix.go @@ -23,22 +23,22 @@ func replaceAndRestart(executable, stagedBinary string) error { backup := executable + ".old" if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("删除旧备份失败: %w", err) + return fmt.Errorf("failed to remove previous backup: %w", err) } if err := os.Rename(executable, backup); err != nil { - return fmt.Errorf("备份当前程序失败: %w", err) + return fmt.Errorf("failed to back up current executable: %w", err) } if err := os.Rename(stagedBinary, executable); err != nil { _ = os.Rename(backup, executable) - return fmt.Errorf("替换当前程序失败: %w", err) + return fmt.Errorf("failed to replace current executable: %w", err) } if err := os.Chmod(executable, installedBinaryMode); err != nil { //nolint:gosec // the installed application binary must be executable. _ = os.Remove(executable) _ = os.Rename(backup, executable) - return fmt.Errorf("设置程序执行权限失败: %w", err) + return fmt.Errorf("failed to set executable permissions: %w", err) } stagingDir := filepath.Dir(stagedBinary) diff --git a/internal/apps/admin/user/errs.go b/internal/apps/admin/user/errs.go index 936eb5a2..616e7038 100644 --- a/internal/apps/admin/user/errs.go +++ b/internal/apps/admin/user/errs.go @@ -6,18 +6,18 @@ package user const ( - userNotFound = "用户不存在" - cannotDisable = "不能禁用管理员用户" - cannotDelete = "不能删除管理员用户" - cannotDeleteSelf = "不能删除当前登录用户" - updateUserFailed = "更新用户状态失败" - deleteUserFailed = "删除用户失败" - usernameExists = "用户名已存在" - usernameRequired = "用户名不能为空" - passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - createUserFailed = "创建用户失败" - emailRequired = "邮箱不能为空" - emailExists = "邮箱已被注册" - cannotRevokeSelfAdmin = "不能撤销当前登录用户的管理员权限" - updateUserInfoFailed = "更新用户信息失败" + userNotFound = "user was not found" + cannotDisable = "administrator accounts cannot be disabled" + cannotDelete = "administrator accounts cannot be deleted" + cannotDeleteSelf = "the currently signed-in user cannot be deleted" + updateUserFailed = "Failed to update user status" + deleteUserFailed = "Failed to delete user" + usernameExists = "username already exists" + usernameRequired = "username must not be empty" + passwordTooShort = "password must contain at least 8 characters" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + createUserFailed = "Failed to create user" + emailRequired = "email address must not be empty" + emailExists = "email address is already registered" + cannotRevokeSelfAdmin = "administrator privileges cannot be revoked from the currently signed-in user" + updateUserInfoFailed = "Failed to update user information" ) diff --git a/internal/apps/admin/user/routers.go b/internal/apps/admin/user/routers.go index 321bb817..f04ff24e 100644 --- a/internal/apps/admin/user/routers.go +++ b/internal/apps/admin/user/routers.go @@ -103,7 +103,7 @@ func abortUserLogicError(c *gin.Context, err error, notFoundMsg string, forbidde return true } logger.ErrorF(c.Request.Context(), "Admin user error: %v", err) - response.AbortInternal(c, "内部服务器错误") + response.AbortInternal(c, "Internal server error") return true } @@ -130,7 +130,7 @@ func ListUsers(c *gin.Context) { total, modelUsers, err := listUsers(c.Request.Context(), req) if err != nil { logger.ErrorF(c.Request.Context(), "List admin users failed: %v", err) - response.AbortInternal(c, "获取用户列表失败") + response.AbortInternal(c, "Failed to get user list") return } diff --git a/internal/apps/agent/agent/runner.go b/internal/apps/agent/agent/runner.go index 66cd2ea1..1f4f1d29 100644 --- a/internal/apps/agent/agent/runner.go +++ b/internal/apps/agent/agent/runner.go @@ -479,7 +479,7 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) (resultErr err } }() if strings.TrimSpace(r.Config.DiscoveryToken) == "" { - return errors.New("agent token is empty and no discovery token is configured") + return errors.New("agent_token is empty and discovery_token is not configured") } registrationKey, err := r.StateStore.EnsureRegistrationKey() if err != nil { diff --git a/internal/apps/agent/config/config.go b/internal/apps/agent/config/config.go index 46b88377..bbd7043d 100644 --- a/internal/apps/agent/config/config.go +++ b/internal/apps/agent/config/config.go @@ -353,25 +353,25 @@ func joinManagedPath(base string, relative string) string { func validate(cfg *Config) error { if cfg.ServerURL == "" { - return errors.New("server_url 不能为空") + return errors.New("server_url must not be empty") } if strings.TrimSpace(cfg.AccessToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" { - return errors.New("agent_token 和 discovery_token 不能同时为空") + return errors.New("agent_token and discovery_token must not both be empty") } if cfg.NodeName == "" { - return errors.New("node_name 不能为空") + return errors.New("node_name must not be empty") } if cfg.NodeIP == "" { - return errors.New("node_ip 不能为空") + return errors.New("node_ip must not be empty") } if cfg.OpenrestyObservabilityPort <= 0 || cfg.OpenrestyObservabilityPort > 65535 { - return errors.New("openresty_observability_port 必须在 1-65535 之间") + return errors.New("openresty_observability_port must be between 1 and 65535") } if cfg.ObservabilityReplayMinutes <= 0 { - return errors.New("observability_replay_minutes 必须大于 0") + return errors.New("observability_replay_minutes must be greater than 0") } if cfg.MMDBUpdateInterval <= 0 { - return errors.New("mmdb_update_interval 必须大于 0") + return errors.New("mmdb_update_interval must be greater than 0") } return nil } @@ -398,10 +398,10 @@ func (cfg *Config) toConfigFile() configFile { // Save persists the current configuration back to its original file path. func (cfg *Config) Save() error { if cfg == nil { - return errors.New("config 不能为空") + return errors.New("config must not be empty") } if cfg.configPath == "" { - return errors.New("config path 未初始化") + return errors.New("configuration path is not initialized") } data, err := json.MarshalIndent(cfg.toConfigFile(), "", " ") //nolint:gosec // agent token must be persisted in local config file if err != nil { diff --git a/internal/apps/agent/nginx/manager.go b/internal/apps/agent/nginx/manager.go index f04f0092..c1cb8cf8 100644 --- a/internal/apps/agent/nginx/manager.go +++ b/internal/apps/agent/nginx/manager.go @@ -325,7 +325,7 @@ func (m *Manager) ensureOpenRestyWorkerReadAccess() error { func (m *Manager) activateConfig(ctx context.Context) error { if m.Executor == nil { - return errors.New("executor 未配置") + return errors.New("Executor is not configured") } if err := m.Executor.Test(ctx); err != nil { return err @@ -407,7 +407,7 @@ func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error { m.runtimeMu.Lock() defer m.runtimeMu.Unlock() if m.Executor == nil { - return errors.New("executor 未配置") + return errors.New("Executor is not configured") } slog.Info("openresty ensure runtime requested", "recreate", recreate) return m.Executor.EnsureRuntime(ctx, recreate) @@ -422,7 +422,7 @@ func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string) func (m *Manager) ensureSafeFallbackRuntime(ctx context.Context, reason string) error { if m.Executor == nil { - return errors.New("executor 未配置") + return errors.New("Executor is not configured") } trimmedReason := strings.TrimSpace(reason) if trimmedReason == "" { @@ -444,7 +444,7 @@ func (m *Manager) CheckHealth(ctx context.Context) error { m.runtimeMu.Lock() defer m.runtimeMu.Unlock() if m.Executor == nil { - return errors.New("executor 未配置") + return errors.New("Executor is not configured") } if m.MainConfigPath != "" { if _, err := os.Stat(m.MainConfigPath); os.IsNotExist(err) { @@ -462,7 +462,7 @@ func (m *Manager) Restart(ctx context.Context) error { m.runtimeMu.Lock() defer m.runtimeMu.Unlock() if m.Executor == nil { - return errors.New("executor 未配置") + return errors.New("Executor is not configured") } slog.Info("openresty restart requested") return m.Executor.Restart(ctx) @@ -473,10 +473,10 @@ func (m *Manager) CurrentChecksum() (string, error) { m.runtimeMu.Lock() defer m.runtimeMu.Unlock() if m.RouteConfigPath == "" { - return "", errors.New("route config path 不能为空") + return "", errors.New("route configuration path must not be empty") } if m.MainConfigPath == "" { - return "", errors.New("main config path 不能为空") + return "", errors.New("main configuration path must not be empty") } mainData, err := os.ReadFile(m.MainConfigPath) if err != nil { @@ -828,10 +828,10 @@ type managedFile struct { func (m *Manager) backup() (*backupState, error) { if m.MainConfigPath == "" { - return nil, errors.New("main config path 不能为空") + return nil, errors.New("main configuration path must not be empty") } if m.RouteConfigPath == "" { - return nil, errors.New("route config path 不能为空") + return nil, errors.New("route configuration path must not be empty") } if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil { return nil, err @@ -1143,10 +1143,10 @@ func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string) func (m *Manager) writeSafeDefaultFallbackFiles() error { if strings.TrimSpace(m.MainConfigPath) == "" { - return errors.New("main config path 不能为空") + return errors.New("main configuration path must not be empty") } if strings.TrimSpace(m.RouteConfigPath) == "" { - return errors.New("route config path 不能为空") + return errors.New("route configuration path must not be empty") } if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil { return err @@ -1203,7 +1203,7 @@ func removeLegacyPowConfig(path string) error { func (m *Manager) certFileTargetPath(relativePath string) (string, error) { if strings.TrimSpace(m.CertDir) == "" { - return "", errors.New("cert dir 不能为空") + return "", errors.New("certificate directory must not be empty") } candidate := strings.TrimSpace(relativePath) if strings.Contains(candidate, `\`) { @@ -1211,7 +1211,7 @@ func (m *Manager) certFileTargetPath(relativePath string) (string, error) { } normalizedPath := filepath.Clean(filepath.FromSlash(candidate)) if normalizedPath == "." || normalizedPath == "" { - return "", errors.New("cert file path 不能为空") + return "", errors.New("certificate file path must not be empty") } if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" { return "", fmt.Errorf("cert file path %q must be relative", relativePath) @@ -1240,7 +1240,7 @@ func certFileMode(relativePath string) fs.FileMode { func luaFileTargetPath(baseDir string, relativePath string) (string, error) { if strings.TrimSpace(baseDir) == "" { - return "", errors.New("lua dir 不能为空") + return "", errors.New("lua directory must not be empty") } candidate := strings.TrimSpace(relativePath) if strings.Contains(candidate, `\`) { @@ -1248,7 +1248,7 @@ func luaFileTargetPath(baseDir string, relativePath string) (string, error) { } normalizedPath := filepath.Clean(filepath.FromSlash(candidate)) if normalizedPath == "." || normalizedPath == "" { - return "", errors.New("lua file path 不能为空") + return "", errors.New("lua file path must not be empty") } if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" { return "", fmt.Errorf("lua file path %q must be relative", relativePath) diff --git a/internal/apps/agent/nginx/pow_assets.go b/internal/apps/agent/nginx/pow_assets.go index 39571142..a02eed7b 100644 --- a/internal/apps/agent/nginx/pow_assets.go +++ b/internal/apps/agent/nginx/pow_assets.go @@ -14,6 +14,12 @@ import ( //go:embed pow_static var powStaticFS embed.FS +//go:embed pow_static/locales/challenge.en.json +var powChallengeEnglish string + +//go:embed pow_static/locales/challenge.zh-CN.json +var powChallengeChinese string + const openRestyPowRuntimeLua = `local _M = {} local source = debug.getinfo(1, "S").source or "" @@ -290,7 +296,7 @@ end return require("pow.runtime").check() ` -const openRestyPowChallengeLua = `local cjson = require "cjson.safe" +var openRestyPowChallengeLua = `local cjson = require "cjson.safe" local pow_challenges = ngx.shared.openflare_pow_challenges local pow_config_dict = ngx.shared.openflare_pow_config @@ -353,21 +359,17 @@ if string.find(accept_lang, "zh") then lang = "zh-CN" end -local t_title = "Making sure you're not a bot!" -local t_status = "Loading..." -local t_protected = "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown." -local t_why = "Why am I seeing this?" -local t_why_desc = "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically." -local t_noscript = "JavaScript is required to pass this verification. Please enable JavaScript and reload." - -if lang == "zh-CN" then - t_title = "正在确认你是不是机器人!" - t_status = "加载中..." - t_protected = "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。" - t_why = "为什么我会看到这个?" - t_why_desc = "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。" - t_noscript = "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。" -end +local translations = { + en = cjson.decode([==[` + powChallengeEnglish + `]==]), + ["zh-CN"] = cjson.decode([==[` + powChallengeChinese + `]==]) +} +local text = translations[lang] or translations.en +local t_title = text.title +local t_status = text.status +local t_protected = text.protected +local t_why = text.why +local t_why_desc = text.why_description +local t_noscript = text.noscript ngx.header.content_type = "text/html; charset=utf-8" ngx.say([[ diff --git a/internal/apps/agent/nginx/pow_static/locales/challenge.en.json b/internal/apps/agent/nginx/pow_static/locales/challenge.en.json new file mode 100644 index 00000000..4edfc699 --- /dev/null +++ b/internal/apps/agent/nginx/pow_static/locales/challenge.en.json @@ -0,0 +1,8 @@ +{ + "title": "Making sure you're not a bot!", + "status": "Loading...", + "protected": "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.", + "why": "Why am I seeing this?", + "why_description": "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.", + "noscript": "JavaScript is required to pass this verification. Please enable JavaScript and reload." +} diff --git a/internal/apps/agent/nginx/pow_static/locales/challenge.zh-CN.json b/internal/apps/agent/nginx/pow_static/locales/challenge.zh-CN.json new file mode 100644 index 00000000..e508ee24 --- /dev/null +++ b/internal/apps/agent/nginx/pow_static/locales/challenge.zh-CN.json @@ -0,0 +1,8 @@ +{ + "title": "正在确认你是不是机器人!", + "status": "加载中...", + "protected": "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。", + "why": "为什么我会看到这个?", + "why_description": "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。", + "noscript": "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。" +} diff --git a/internal/apps/cap/errs.go b/internal/apps/cap/errs.go index 9fd43234..3b9c6ec2 100644 --- a/internal/apps/cap/errs.go +++ b/internal/apps/cap/errs.go @@ -5,6 +5,6 @@ package cap const ( - errCapTokenMissing = "验证码验证失败,缺少验证码凭证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errCapTokenMissing = "CAPTCHA verification failed: missing verification credential" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errCapTokenInvalidOrExpired = "CAPTCHA verification failed or expired; please try again" //nolint:gosec // false positive: this is an error message, not hardcoded credentials ) diff --git a/internal/apps/cap/routers.go b/internal/apps/cap/routers.go index 30e2afe7..8e3cc9e1 100644 --- a/internal/apps/cap/routers.go +++ b/internal/apps/cap/routers.go @@ -70,7 +70,7 @@ func Challenge(c *gin.Context) { func Redeem(c *gin.Context) { var req redeemRequest if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, "无效的参数") + response.AbortBadRequest(c, "invalid parameters") return } diff --git a/internal/apps/flared/config/config.go b/internal/apps/flared/config/config.go index 0e61a88a..397303f3 100644 --- a/internal/apps/flared/config/config.go +++ b/internal/apps/flared/config/config.go @@ -115,10 +115,10 @@ func applyDefaults(cfg *Config, baseDir string) { func validate(cfg *Config) error { if cfg.ServerURL == "" { - return errors.New("server_url 不能为空") + return errors.New("server_url must not be empty") } if strings.TrimSpace(cfg.TunnelToken) == "" { - return errors.New("tunnel_token 不能为空") + return errors.New("tunnel_token must not be empty") } return nil } @@ -134,10 +134,10 @@ func (cfg *Config) InitialAuthToken() string { // Save writes the current configuration back to the loaded config path. func (cfg *Config) Save() error { if cfg == nil { - return errors.New("config 不能为空") + return errors.New("config must not be empty") } if cfg.configPath == "" { - return errors.New("config path 未初始化") + return errors.New("configuration path is not initialized") } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { diff --git a/internal/apps/oauth/errs.go b/internal/apps/oauth/errs.go index e25a78f1..92cd224a 100644 --- a/internal/apps/oauth/errs.go +++ b/internal/apps/oauth/errs.go @@ -6,18 +6,18 @@ package oauth // OAuth 认证相关错误消息 const ( - errInvalidState = "非法登录请求" - errIDTokenVerifyFailed = "ID Token 验证失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errInvalidState = "Invalid login request" + errIDTokenVerifyFailed = "ID token verification failed" //nolint:gosec // false positive: this is an error message, not hardcoded credentials errIDTokenVerifyFailedFormat = "%s: %w" - errNonceMismatch = "nonce 不匹配,可能存在重放攻击" - errNoActiveAuthSource = "未配置可用认证源" - errServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试" - errAuthSourceRequired = "认证源不能为空" - errDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL" - errUsernameGenerateFailed = "无法生成可用用户名" - errUsernameFromSourceFailed = "无法从认证源获取用户名" - errAuthSourceDisabled = "认证源未启用" - errInvalidExternalAccountBindingID = "绑定记录 ID 无效" - ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errOAuthStateRateLimited = "登录请求过于频繁,请稍后再试" + errNonceMismatch = "nonce mismatch; possible replay attack" + errNoActiveAuthSource = "no available authentication source is configured" + errServerAddressMissing = "server address (server_address) is not configured; configure it in system settings and try again" + errAuthSourceRequired = "authentication source must not be empty" + errDiscoveryURLRequired = "OIDC authentication sources require a Discovery URL" + errUsernameGenerateFailed = "unable to generate an available username" + errUsernameFromSourceFailed = "unable to get username from authentication source" + errAuthSourceDisabled = "Authentication source is disabled" + errInvalidExternalAccountBindingID = "Invalid account binding ID" + ErrTokenAuthNotAllowed = "This endpoint does not allow access-token authentication" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errOAuthStateRateLimited = "too many login requests; please try again later" ) diff --git a/internal/apps/openflare/agent/errs.go b/internal/apps/openflare/agent/errs.go index 9244622e..8ee4aec1 100644 --- a/internal/apps/openflare/agent/errs.go +++ b/internal/apps/openflare/agent/errs.go @@ -4,19 +4,19 @@ package agent const ( - errMissingAgentToken = "缺少 Agent Token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errInvalidAgentToken = "无权进行此操作,Agent Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errInvalidDiscoveryToken = "无权进行此操作,注册 Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errMissingAgentToken = "missing agent token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errInvalidAgentToken = "Operation not authorized: invalid agent token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errInvalidDiscoveryToken = "Operation not authorized: invalid registration token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials errNodeMissingFromContext = "Node object missing from context" - errNoActiveConfig = "当前没有激活版本" - errNodeNotFound = "节点不存在" - errNodeIDRequired = "node_id 不能为空" - errVersionRequired = "version 不能为空" - errInvalidApplyResult = "result 仅支持 success、warning 或 failed" - errIPRequired = "ip 不能为空" - errIPInvalid = "ip 格式无效" - errAgentVersionRequired = "version 不能为空" - errNodeIDConflict = "节点标识生成冲突,请重试" + errNoActiveConfig = "no active configuration version" + errNodeNotFound = "node was not found" + errNodeIDRequired = "node_id must not be empty" + errVersionRequired = "version must not be empty" + errInvalidApplyResult = "result must be success, warning or failed" + errIPRequired = "ip must not be empty" + errIPInvalid = "invalid IP address format" + errAgentVersionRequired = "version must not be empty" + errNodeIDConflict = "node identifier conflict; please try again" errRegistrationKeyInvalid = "invalid discovery registration key" errRegistrationCredentialChanged = "discovery enrollment credential has changed; use the assigned agent token" ) diff --git a/internal/apps/openflare/agent/routers.go b/internal/apps/openflare/agent/routers.go index 15cfc9a5..dcb1a3d2 100644 --- a/internal/apps/openflare/agent/routers.go +++ b/internal/apps/openflare/agent/routers.go @@ -270,12 +270,12 @@ func DownloadPagesProjectLatestPackageHandler(c *gin.Context) { func pagesUintParam(c *gin.Context, name string) (uint, bool) { raw := c.Param(name) if raw == "" { - response.AbortBadRequest(c, "无效的 ID") + response.AbortBadRequest(c, "Invalid ID") return 0, false } id64, err := strconv.ParseUint(raw, 10, 64) if err != nil || id64 == 0 { - response.AbortBadRequest(c, "无效的 ID") + response.AbortBadRequest(c, "Invalid ID") return 0, false } return uint(id64), true diff --git a/internal/apps/openflare/agent/waf_ip_group.go b/internal/apps/openflare/agent/waf_ip_group.go index a13886a7..cfdc7683 100644 --- a/internal/apps/openflare/agent/waf_ip_group.go +++ b/internal/apps/openflare/agent/waf_ip_group.go @@ -124,7 +124,7 @@ func buildAgentWAFIPGroups(ctx context.Context, ids []uint) ([]WAFIPGroup, error func buildAgentWAFIPGroup(group *model.OpenFlareWAFIPGroup) (WAFIPGroup, error) { if group == nil { - return WAFIPGroup{}, errors.New("IP 组不存在") + return WAFIPGroup{}, errors.New("IP group was not found") } ips, err := decodeWAFIPGroupStringList(group.IPList) if err != nil { @@ -192,7 +192,7 @@ func activeConfigWAFIPGroupIDs(ctx context.Context) ([]uint, error) { } ids, err := runtimeIPMatchGroupIDs(node.Config) if err != nil { - return nil, fmt.Errorf("活动配置 WAF 规则 %d 节点 %s 的 IP 匹配配置无效: %w", group.ID, nodeID, err) + return nil, fmt.Errorf("invalid IP match configuration in active WAF rule %d, node %s: %w", group.ID, nodeID, err) } for _, id := range ids { if id > 0 { diff --git a/internal/apps/openflare/agent/waf_ip_group_test.go b/internal/apps/openflare/agent/waf_ip_group_test.go index 327cabe3..142a52d2 100644 --- a/internal/apps/openflare/agent/waf_ip_group_test.go +++ b/internal/apps/openflare/agent/waf_ip_group_test.go @@ -150,8 +150,8 @@ func TestChangedWAFIPGroupsForAgentRejectsMalformedIPMatchConfig(t *testing.T) { }).Error) _, err := ChangedWAFIPGroupsForAgent(ctx, nil, nil) - require.ErrorContains(t, err, "规则 7 节点 match") - require.ErrorContains(t, err, "IP 匹配配置无效") + require.ErrorContains(t, err, "rule 7, node match") + require.ErrorContains(t, err, "invalid IP match configuration") } func TestChangedWAFIPGroupsForAgentRejectsOversizedSnapshotBeforeChecksumDelta(t *testing.T) { @@ -172,8 +172,8 @@ func TestChangedWAFIPGroupsForAgentRejectsOversizedSnapshotBeforeChecksumDelta(t _, err = ChangedWAFIPGroupsForAgent(ctx, []uint{ipGroup.ID}, map[string]string{ strconv.FormatUint(uint64(ipGroup.ID), 10): agentGroup.Checksum, }) - require.ErrorContains(t, err, "WAF IP 组快照大小") - require.ErrorContains(t, err, "超过上限") + require.ErrorContains(t, err, "WAF IP group snapshot size") + require.ErrorContains(t, err, "exceeds the limit") } func TestChangedWAFIPGroupsForAgentReturnsChecksumDelta(t *testing.T) { diff --git a/internal/apps/openflare/apiutil/bind.go b/internal/apps/openflare/apiutil/bind.go index 52da29ee..a5333086 100644 --- a/internal/apps/openflare/apiutil/bind.go +++ b/internal/apps/openflare/apiutil/bind.go @@ -11,8 +11,8 @@ import ( "github.com/gin-gonic/gin" ) -const errInvalidParams = "参数错误" -const errInvalidID = "无效的 ID" +const errInvalidParams = "invalid parameters" +const errInvalidID = "Invalid ID" // BindJSON binds JSON body; returns false after aborting with 400. func BindJSON(c *gin.Context, dst any) bool { diff --git a/internal/apps/openflare/apply_log/errs.go b/internal/apps/openflare/apply_log/errs.go index 54eede47..ff091071 100644 --- a/internal/apps/openflare/apply_log/errs.go +++ b/internal/apps/openflare/apply_log/errs.go @@ -6,5 +6,5 @@ package apply_log const ( - errRetentionDaysOutOfRange = "retention_days 必须在 1 到 3650 之间" + errRetentionDaysOutOfRange = "retention_days must be between 1 and 3650" ) diff --git a/internal/apps/openflare/async_tasks.go b/internal/apps/openflare/async_tasks.go index 7a4944d3..32701d7b 100644 --- a/internal/apps/openflare/async_tasks.go +++ b/internal/apps/openflare/async_tasks.go @@ -49,8 +49,8 @@ var ( var SSLRenewMeta = task.TaskMeta{ Type: TaskTypeSSLRenew, AsynqTask: SSLRenewTask, - Name: "OpenFlare SSL 自动续期", - Description: "扫描即将到期的 ACME 证书并触发自动续期", + Name: "OpenFlare automatic SSL renewal", + Description: "Scan ACME certificates nearing expiration and renew them automatically", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -61,8 +61,8 @@ var SSLRenewMeta = task.TaskMeta{ var WAFIPGroupSyncMeta = task.TaskMeta{ Type: TaskTypeWAFIPGroupSync, AsynqTask: WAFIPGroupSyncTask, - Name: "OpenFlare WAF IP 组同步", - Description: "同步到期的自动规则与订阅类型 WAF IP 组", + Name: "OpenFlare WAF IP group synchronization", + Description: "Synchronize automatic and subscription WAF IP groups that are due for an update", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -73,8 +73,8 @@ var WAFIPGroupSyncMeta = task.TaskMeta{ var UptimeKumaSyncMeta = task.TaskMeta{ Type: TaskTypeUptimeKumaSync, AsynqTask: UptimeKumaSyncTask, - Name: "OpenFlare Uptime Kuma 同步", - Description: "将启用的代理规则同步到 Uptime Kuma 监控", + Name: "OpenFlare Uptime Kuma synchronization", + Description: "Synchronize enabled proxy routes with Uptime Kuma monitors", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -85,15 +85,15 @@ var UptimeKumaSyncMeta = task.TaskMeta{ var LogDBSwitchMeta = task.TaskMeta{ Type: TaskTypeLogDBSwitch, AsynqTask: LogDBSwitchTask, - Name: "切换日志数据库", - Description: "复制迁移日志数据并在成功后切换日志主库(期间禁止日志写入)", + Name: "Switch log database", + Description: "Copy log data and switch the log database on success; log writes are paused during migration", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, Retryable: true, Params: []task.TaskParam{ - {Name: "target", Label: "目标日志库", Type: "string", Required: true, - Placeholder: "postgres|sqlite|clickhouse", Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse"}, + {Name: "target", Label: "Target log database", Type: "string", Required: true, + Placeholder: "postgres|sqlite|clickhouse", Description: "Migration target: postgres for a PostgreSQL primary, sqlite for a SQLite primary, or clickhouse"}, }, } @@ -102,12 +102,12 @@ type SSLRenewHandler struct{} // Execute runs SSL certificate renewal for all due certificates. func (h *SSLRenewHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) { - task.AppendLog(ctx, "开始扫描待续期证书") + task.AppendLog(ctx, "Scanning certificates due for renewal") if err := tasks.RunSSLRenewJob(ctx); err != nil { - task.AppendLog(ctx, "SSL 自动续期失败: %v", err) + task.AppendLog(ctx, "Automatic SSL renewal failed: %v", err) return nil, err } - msg := "SSL 自动续期任务完成" + msg := "Automatic SSL renewal task completed" task.AppendLog(ctx, "%s", msg) return &task.TaskResult{Message: msg}, nil } @@ -117,12 +117,12 @@ type WAFIPGroupSyncHandler struct{} // Execute syncs all due automatic/subscription WAF IP groups. func (h *WAFIPGroupSyncHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) { - task.AppendLog(ctx, "开始同步到期的 WAF IP 组") + task.AppendLog(ctx, "Synchronizing WAF IP groups due for an update") if err := waf.SyncDueWAFIPGroups(ctx); err != nil { - task.AppendLog(ctx, "WAF IP 组同步失败: %v", err) + task.AppendLog(ctx, "WAF IP group synchronization failed: %v", err) return nil, err } - msg := "WAF IP 组同步完成" + msg := "WAF IP group synchronization completed" task.AppendLog(ctx, "%s", msg) return &task.TaskResult{Message: msg}, nil } @@ -135,7 +135,7 @@ func (h *UptimeKumaSyncHandler) Execute(ctx context.Context, _ []byte) (*task.Ta // 从 SystemConfig 读取 UptimeKuma 配置 enabled, _ := repository.GetBoolByKey(ctx, model.ConfigKeyUptimeKumaEnabled) if !enabled { - msg := "Uptime Kuma 集成未启用,跳过执行" + msg := "Uptime Kuma integration is disabled; skipping execution" task.AppendLog(ctx, "%s", msg) return &task.TaskResult{Message: msg}, nil } @@ -145,26 +145,26 @@ func (h *UptimeKumaSyncHandler) Execute(ctx context.Context, _ []byte) (*task.Ta interval = 5 } if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute { - msg := fmt.Sprintf("距上次同步不足 %d 分钟,跳过执行", interval) + msg := fmt.Sprintf("Less than %d minutes since the last synchronization; skipping execution", interval) task.AppendLog(ctx, "%s", msg) return &task.TaskResult{Message: msg}, nil } if !uptimeKumaSyncMutex.TryLock() { - msg := "Uptime Kuma 同步任务正在执行,跳过本次调度" + msg := "Uptime Kuma synchronization is already running; skipping this schedule" task.AppendLog(ctx, "%s", msg) return &task.TaskResult{Message: msg}, nil } defer uptimeKumaSyncMutex.Unlock() - task.AppendLog(ctx, "开始同步代理规则到 Uptime Kuma") + task.AppendLog(ctx, "Synchronizing proxy routes with Uptime Kuma") if err := uptimekuma.SyncToUptimeKuma(ctx); err != nil { - task.AppendLog(ctx, "Uptime Kuma 同步失败: %v", err) + task.AppendLog(ctx, "Uptime Kuma synchronization failed: %v", err) return nil, err } lastUptimeKumaSyncTime = time.Now() - msg := "Uptime Kuma 同步完成" + msg := "Uptime Kuma synchronization completed" task.AppendLog(ctx, "%s", msg) return &task.TaskResult{Message: msg}, nil } diff --git a/internal/apps/openflare/async_tasks_test.go b/internal/apps/openflare/async_tasks_test.go index dc99212f..6ecd230b 100644 --- a/internal/apps/openflare/async_tasks_test.go +++ b/internal/apps/openflare/async_tasks_test.go @@ -31,5 +31,5 @@ func TestUptimeKumaSyncHandlerSkipsWhenDisabled(t *testing.T) { result, err := (&UptimeKumaSyncHandler{}).Execute(ctx, nil) require.NoError(t, err) require.NotNil(t, result) - assert.Contains(t, result.Message, "未启用") + assert.Contains(t, result.Message, "disabled") } diff --git a/internal/apps/openflare/cloudflare/client.go b/internal/apps/openflare/cloudflare/client.go index 3ae6e8e4..71613731 100644 --- a/internal/apps/openflare/cloudflare/client.go +++ b/internal/apps/openflare/cloudflare/client.go @@ -118,7 +118,7 @@ func (client *HTTPClient) VerifyToken(ctx context.Context) error { return err } if result.Status != "active" { - return errors.New("cloudflare API Token 未激活") + return errors.New("cloudflare API token is inactive") } return nil } @@ -131,7 +131,7 @@ func (client *HTTPClient) FindZone(ctx context.Context, name string) (*Zone, err return nil, err } if len(zones) != 1 { - return nil, fmt.Errorf("cloudflare 中未找到唯一 Zone %s", name) + return nil, fmt.Errorf("no unique Cloudflare zone found for %s", name) } return &zones[0], nil } @@ -220,7 +220,7 @@ func (client *HTTPClient) do(ctx context.Context, method, path string, query url } return decodeAPIResponse(statusCode, responseBody, result) } - return errors.New("cloudflare API 请求超过重试次数") + return errors.New("cloudflare API retry limit exceeded") } func encodeRequestBody(body any) ([]byte, error) { @@ -251,7 +251,7 @@ func (client *HTTPClient) send(ctx context.Context, method, requestURL string, b request.Header.Set("Content-Type", "application/json") response, err := client.httpClient.Do(request) if err != nil { - return 0, "", nil, fmt.Errorf("cloudflare API 请求失败: %w", err) + return 0, "", nil, fmt.Errorf("cloudflare API request failed: %w", err) } responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxResponseBodyBytes)) closeErr := response.Body.Close() @@ -281,7 +281,7 @@ func decodeAPIResponse(statusCode int, responseBody []byte, result any) error { return fmt.Errorf("decode Cloudflare response: %w", err) } if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices || !envelope.Success { - message := "cloudflare API 请求失败" + message := "Cloudflare API request failed" if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" { message = envelope.Errors[0].Message } diff --git a/internal/apps/openflare/cloudflare/errs.go b/internal/apps/openflare/cloudflare/errs.go index a15a6bbf..4d9424f9 100644 --- a/internal/apps/openflare/cloudflare/errs.go +++ b/internal/apps/openflare/cloudflare/errs.go @@ -4,25 +4,25 @@ package cloudflare const ( - errConnectionNotConfigured = "尚未配置 Cloudflare 连接" - errConnectionSourceInvalid = "无效的 Cloudflare 连接来源" - errStandaloneInputRequired = "请填写 Cloudflare API Token" - errStandaloneInputInvalid = "配置的 Cloudflare API Token 无效" - errDNSAccountInvalid = "请选择有效的 Cloudflare DNS 账号" - errGroupNameRequired = "分组名称不能为空" - errGroupNodeSame = "主节点和备用节点不能相同" - errGroupTargetModeInvalid = "请选择有效的指向方式" - errRecordTypeInvalid = "请选择 CNAME、A 或 AAAA 记录类型" - errRecordContentInvalid = "DNS 记录值无效,请检查域名或 IP 地址" - errNodeInvalid = "请选择有效的边缘节点" - errNodeIPv4Required = "生效节点必须配置合法 IPv4" - errGroupDisabled = "指向分组已停用" - errMemberExists = "该域名已加入其他指向分组" - errMultipleDNSRecords = "检测到 Cloudflare 中存在多条同名 DNS 记录,请先手动清理" - errSyncFailed = "Cloudflare DNS 同步失败" - errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败" - errTaskDispatchFailed = "无法投递 Cloudflare 同步任务" - errTargetGroupSame = "目标分组不能为当前分组" - errTargetGroupInvalid = "目标分组不存在" - errNoMembersSelected = "未选择任何成员" + errConnectionNotConfigured = "no Cloudflare connection is configured" + errConnectionSourceInvalid = "invalid Cloudflare connection source" + errStandaloneInputRequired = "enter a Cloudflare API token" + errStandaloneInputInvalid = "configured Cloudflare API token is invalid" + errDNSAccountInvalid = "select a valid Cloudflare DNS account" + errGroupNameRequired = "group name must not be empty" + errGroupNodeSame = "primary and standby nodes must be different" + errGroupTargetModeInvalid = "select a valid target mode" + errRecordTypeInvalid = "select a CNAME, A or AAAA record type" + errRecordContentInvalid = "invalid DNS record value; check the domain name or IP address" + errNodeInvalid = "select a valid edge node" + errNodeIPv4Required = "the active node requires a valid IPv4 address" + errGroupDisabled = "target group is disabled" + errMemberExists = "this domain already belongs to another target group" + errMultipleDNSRecords = "multiple Cloudflare DNS records have the same name; remove the duplicates manually first" + errSyncFailed = "Cloudflare DNS synchronization failed" + errDeleteRemoteFailed = "failed to delete Cloudflare DNS record" + errTaskDispatchFailed = "unable to enqueue Cloudflare synchronization task" + errTargetGroupSame = "target group must differ from the current group" + errTargetGroupInvalid = "target group was not found" + errNoMembersSelected = "no members selected" ) diff --git a/internal/apps/openflare/cloudflare/routers.go b/internal/apps/openflare/cloudflare/routers.go index c5150aae..78f33fb8 100644 --- a/internal/apps/openflare/cloudflare/routers.go +++ b/internal/apps/openflare/cloudflare/routers.go @@ -19,7 +19,7 @@ func abortLogic(c *gin.Context, err error) bool { } switch { case errors.Is(err, gorm.ErrRecordNotFound): - response.AbortNotFound(c, "Cloudflare 资源不存在") + response.AbortNotFound(c, "Cloudflare resource was not found") case err.Error() == errMemberExists: response.AbortConflict(c, err.Error()) case err.Error() == errTaskDispatchFailed: diff --git a/internal/apps/openflare/cloudflare/tasks.go b/internal/apps/openflare/cloudflare/tasks.go index cc6bd6a8..b8376138 100644 --- a/internal/apps/openflare/cloudflare/tasks.go +++ b/internal/apps/openflare/cloudflare/tasks.go @@ -42,8 +42,8 @@ const ( var FailoverCheckMeta = task.TaskMeta{ Type: TaskTypeFailoverCheck, AsynqTask: FailoverCheckTask, - Name: "Cloudflare 节点故障回退检查", - Description: "根据节点在线和应用状态切换分组主备节点", + Name: "Cloudflare node failover check", + Description: "Switch group primary and standby nodes based on node availability and configuration status", SupportsTime: false, MaxRetry: 2, Queue: task.QueueDefault, @@ -55,8 +55,8 @@ var FailoverCheckMeta = task.TaskMeta{ var SyncMemberMeta = task.TaskMeta{ Type: TaskTypeSyncMember, AsynqTask: SyncMemberTask, - Name: "Cloudflare 域名同步", - Description: "同步单个域名的 Cloudflare DNS 记录", + Name: "Cloudflare domain synchronization", + Description: "Synchronize Cloudflare DNS records for a single domain", SupportsTime: false, MaxRetry: 3, Queue: task.QueueDefault, @@ -64,11 +64,11 @@ var SyncMemberMeta = task.TaskMeta{ Params: []task.TaskParam{ { Name: "member_id", - Label: "成员 ID", + Label: "Member ID", Type: "number", Required: true, - Placeholder: "请输入 Cloudflare 指向成员 ID", - Description: "of_cf_pointing_members 表中的成员主键 ID", + Placeholder: "Enter the Cloudflare target member ID", + Description: "Member primary key in the of_cf_pointing_members table", }, }, } @@ -77,8 +77,8 @@ var SyncMemberMeta = task.TaskMeta{ var SyncGroupMeta = task.TaskMeta{ Type: TaskTypeSyncGroup, AsynqTask: SyncGroupTask, - Name: "Cloudflare 分组同步", - Description: "同步指向分组内全部域名", + Name: "Cloudflare group synchronization", + Description: "Synchronize all domains in a target group", SupportsTime: false, MaxRetry: 2, Queue: task.QueueDefault, @@ -86,11 +86,11 @@ var SyncGroupMeta = task.TaskMeta{ Params: []task.TaskParam{ { Name: "group_id", - Label: "分组 ID", + Label: "Group ID", Type: "number", Required: true, - Placeholder: "请输入 Cloudflare 指向分组 ID", - Description: "of_cf_pointing_groups 表中的分组主键 ID", + Placeholder: "Enter the Cloudflare target group ID", + Description: "Group primary key in the of_cf_pointing_groups table", }, }, } @@ -99,8 +99,8 @@ var SyncGroupMeta = task.TaskMeta{ var SyncByNodeMeta = task.TaskMeta{ Type: TaskTypeSyncByNode, AsynqTask: SyncByNodeTask, - Name: "Cloudflare 节点同步", - Description: "同步当前指向指定节点的全部域名", + Name: "Cloudflare node synchronization", + Description: "Synchronize all domains currently targeting the specified node", SupportsTime: false, MaxRetry: 2, Queue: task.QueueDefault, @@ -161,14 +161,14 @@ type SyncMemberTaskHandler struct{} // ValidatePayload validates a one-member task payload. func (handler *SyncMemberTaskHandler) ValidatePayload(payload []byte) ([]byte, error) { if len(payload) == 0 { - return nil, errors.New("任务参数不能为空") + return nil, errors.New("task parameters must not be empty") } var input SyncMemberPayload if err := decodePayload(payload, &input); err != nil { - return nil, fmt.Errorf("无效的 Cloudflare 成员同步参数: %w", err) + return nil, fmt.Errorf("invalid Cloudflare member synchronization parameters: %w", err) } if input.MemberID == 0 { - return nil, errors.New("成员 ID 不能为空或零") + return nil, errors.New("member ID must be nonzero") } return json.Marshal(input) } @@ -184,14 +184,14 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt state, loadErr := repository.GetCFPointingMemberContext(ctx, input.MemberID) if loadErr != nil { - task.AppendLog(ctx, "加载成员上下文失败: member_id=%d error=%v", input.MemberID, loadErr) + task.AppendLog(ctx, "Failed to load member context: member_id=%d error=%v", input.MemberID, loadErr) } else { target := fmt.Sprintf("%s(%s)", state.Node.Name, strings.TrimSpace(state.Node.IP)) if state.Group.TargetMode == model.CFPointingTargetModeCustom { target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent) } task.AppendLog(ctx, - "开始域名同步: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d", + "Starting domain synchronization: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d", state.Domain.Domain, state.Zone.Domain, state.Group.Name, @@ -204,28 +204,28 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt if err = ReconcileMember(ctx, input.MemberID); err != nil { if state != nil { - task.AppendLog(ctx, "域名同步失败: domain=%s member_id=%d error=%v", + task.AppendLog(ctx, "Domain synchronization failed: domain=%s member_id=%d error=%v", state.Domain.Domain, input.MemberID, err) } else { - task.AppendLog(ctx, "域名同步失败: member_id=%d error=%v", input.MemberID, err) + task.AppendLog(ctx, "Domain synchronization failed: member_id=%d error=%v", input.MemberID, err) } return nil, fmt.Errorf("%s: %w", errSyncFailed, err) } - message := "Cloudflare 域名同步成功" + message := "Cloudflare domain synchronization succeeded" if state != nil { target := strings.TrimSpace(state.Node.IP) if state.Group.TargetMode == model.CFPointingTargetModeCustom { target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent) } - message = fmt.Sprintf("Cloudflare 域名同步成功: %s → %s (proxied=%v)", + message = fmt.Sprintf("Cloudflare domain synchronization succeeded: %s -> %s (proxied=%v)", state.Domain.Domain, target, state.Member.Proxied) task.AppendLog(ctx, - "域名同步成功: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s", + "Domain synchronization succeeded: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s", state.Domain.Domain, target, state.Member.Proxied, state.Group.Name, state.Group.TargetMode, ) } else { - task.AppendLog(ctx, "域名同步成功: member_id=%d", input.MemberID) + task.AppendLog(ctx, "Domain synchronization succeeded: member_id=%d", input.MemberID) } return &task.TaskResult{Message: message}, nil } @@ -236,14 +236,14 @@ type SyncGroupTaskHandler struct{} // ValidatePayload validates a group task payload. func (handler *SyncGroupTaskHandler) ValidatePayload(payload []byte) ([]byte, error) { if len(payload) == 0 { - return nil, errors.New("任务参数不能为空") + return nil, errors.New("task parameters must not be empty") } var input SyncGroupPayload if err := decodePayload(payload, &input); err != nil { - return nil, fmt.Errorf("无效的 Cloudflare 分组同步参数: %w", err) + return nil, fmt.Errorf("invalid Cloudflare group synchronization parameters: %w", err) } if input.GroupID == 0 { - return nil, errors.New("分组 ID 不能为空或零") + return nil, errors.New("group ID must be nonzero") } return json.Marshal(input) } @@ -262,18 +262,18 @@ func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte scopeName := fmt.Sprintf("#%d", input.GroupID) activeNode := "" if group, groupErr := repository.GetCFPointingGroup(ctx, input.GroupID); groupErr != nil { - task.AppendLog(ctx, "加载分组失败: group_id=%d error=%v", input.GroupID, groupErr) + task.AppendLog(ctx, "Failed to load group: group_id=%d error=%v", input.GroupID, groupErr) } else { scopeName = group.Name activeNode = groupSyncTarget(ctx, group) task.AppendLog(ctx, - "准备分组同步: group=%s id=%d enabled=%v target=%s default_proxied=%v", + "Preparing group synchronization: group=%s id=%d enabled=%v target=%s default_proxied=%v", group.Name, group.ID, group.Enabled, activeNode, group.DefaultProxied, ) } members, err := repository.ListCFPointingMembersByGroupID(ctx, input.GroupID) - return executeBatchSync(ctx, members, err, "分组", scopeName, input.GroupID, activeNode) + return executeBatchSync(ctx, members, err, "group", scopeName, input.GroupID, activeNode) } func groupSyncTarget(ctx context.Context, group *model.CFPointingGroup) string { @@ -282,7 +282,7 @@ func groupSyncTarget(ctx context.Context, group *model.CFPointingGroup) string { } node, err := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID) if err != nil { - task.AppendLog(ctx, "加载生效节点失败: group=%s active_node_id=%d error=%v", + task.AppendLog(ctx, "Failed to load active node: group=%s active_node_id=%d error=%v", group.Name, group.ActiveNodeID, err) return "" } @@ -296,7 +296,7 @@ type SyncByNodeTaskHandler struct{} func (handler *SyncByNodeTaskHandler) ValidatePayload(payload []byte) ([]byte, error) { var input SyncByNodePayload if err := decodePayload(payload, &input); err != nil || input.NodeID == 0 { - return nil, errors.New("无效的 Cloudflare 节点同步参数") + return nil, errors.New("invalid Cloudflare node synchronization parameters") } return json.Marshal(input) } @@ -315,16 +315,16 @@ func (handler *SyncByNodeTaskHandler) Execute(ctx context.Context, payload []byt scopeName := fmt.Sprintf("#%d", input.NodeID) activeNode := "" if node, nodeErr := repository.GetOpenFlareNodeByID(ctx, input.NodeID); nodeErr != nil { - task.AppendLog(ctx, "加载节点失败: node_id=%d error=%v", input.NodeID, nodeErr) + task.AppendLog(ctx, "Failed to load node: node_id=%d error=%v", input.NodeID, nodeErr) } else { scopeName = node.Name activeNode = fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP)) - task.AppendLog(ctx, "准备节点同步: node=%s id=%d ip=%s", + task.AppendLog(ctx, "Preparing node synchronization: node=%s id=%d ip=%s", node.Name, node.ID, strings.TrimSpace(node.IP)) } members, err := repository.ListCFPointingMembersByActiveNodeID(ctx, input.NodeID) - return executeBatchSync(ctx, members, err, "节点", scopeName, input.NodeID, activeNode) + return executeBatchSync(ctx, members, err, "node", scopeName, input.NodeID, activeNode) } func executeBatchSync( @@ -336,15 +336,15 @@ func executeBatchSync( activeNode string, ) (*task.TaskResult, error) { if listErr != nil { - task.AppendLog(ctx, "列出%s成员失败: name=%s id=%d error=%v", + task.AppendLog(ctx, "Failed to list %s members: name=%s id=%d error=%v", scope, scopeName, scopeID, listErr) return nil, listErr } - task.AppendLog(ctx, "开始%s同步: name=%s id=%d target=%s 域名数=%d", + task.AppendLog(ctx, "Starting %s synchronization: name=%s id=%d target=%s domains=%d", scope, scopeName, scopeID, activeNode, len(members)) if len(members) == 0 { - message := fmt.Sprintf("Cloudflare %s同步完成: %s 无域名成员", scope, scopeName) + message := fmt.Sprintf("Cloudflare %s synchronization completed: %s has no domain members", scope, scopeName) task.AppendLog(ctx, "%s", message) return &task.TaskResult{Message: message}, nil } @@ -355,28 +355,28 @@ func executeBatchSync( if domain, domainErr := repository.GetZoneDomainByID(ctx, member.ZoneDomainID); domainErr == nil { domainName = domain.Domain } else if errors.Is(domainErr, gorm.ErrRecordNotFound) { - task.AppendLog(ctx, "[%d/%d] 域名记录已不存在,清理孤立成员: member_id=%d zone_domain_id=%d", + task.AppendLog(ctx, "[%d/%d] Domain record no longer exists; removing orphaned member: member_id=%d zone_domain_id=%d", index+1, len(members), member.ID, member.ZoneDomainID) if delErr := repository.DeleteCFPointingMember(ctx, &member); delErr != nil { - task.AppendLog(ctx, "[%d/%d] 清理孤立成员失败: member_id=%d error=%v", + task.AppendLog(ctx, "[%d/%d] Failed to remove orphaned member: member_id=%d error=%v", index+1, len(members), member.ID, delErr) } continue } - task.AppendLog(ctx, "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)", + task.AppendLog(ctx, "[%d/%d] Synchronizing domain %s (member_id=%d proxied=%v)", index+1, len(members), domainName, member.ID, member.Proxied) if err := ReconcileMember(ctx, member.ID); err != nil { - task.AppendLog(ctx, "[%d/%d] 失败: domain=%s member_id=%d error=%v", + task.AppendLog(ctx, "[%d/%d] Failed: domain=%s member_id=%d error=%v", index+1, len(members), domainName, member.ID, err) return nil, err } syncedCount++ - task.AppendLog(ctx, "[%d/%d] 成功: domain=%s", index+1, len(members), domainName) + task.AppendLog(ctx, "[%d/%d] Succeeded: domain=%s", index+1, len(members), domainName) } - message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, syncedCount) + message := fmt.Sprintf("Cloudflare %s synchronization completed: %s, %d domains", scope, scopeName, syncedCount) if activeNode != "" { - message = fmt.Sprintf("Cloudflare %s同步完成: %s → %s,共 %d 个域名", + message = fmt.Sprintf("Cloudflare %s synchronization completed: %s -> %s, %d domains", scope, scopeName, activeNode, syncedCount) } task.AppendLog(ctx, "%s", message) @@ -403,7 +403,7 @@ type FailoverCheckTaskHandler struct{} func (handler *FailoverCheckTaskHandler) ValidatePayload(payload []byte) ([]byte, error) { var input struct{} if err := decodePayload(payload, &input); err != nil { - return nil, fmt.Errorf("无效的 Cloudflare 故障回退检查参数: %w", err) + return nil, fmt.Errorf("invalid Cloudflare failover check parameters: %w", err) } return json.Marshal(input) } @@ -413,13 +413,13 @@ func (handler *FailoverCheckTaskHandler) Execute(ctx context.Context, payload [] if _, err := handler.ValidatePayload(payload); err != nil { return nil, task.PermanentError(err.Error()) } - task.AppendLog(ctx, "开始检查 Cloudflare 节点分组主备状态") + task.AppendLog(ctx, "Checking Cloudflare group primary and standby node status") changed, err := CheckNodeFailover(ctx) if err != nil { - task.AppendLog(ctx, "Cloudflare 节点故障回退检查失败: error=%v", err) + task.AppendLog(ctx, "Cloudflare node failover check failed: error=%v", err) return nil, err } - message := fmt.Sprintf("Cloudflare 节点故障回退检查完成,切换分组 %d 个", changed) + message := fmt.Sprintf("Cloudflare node failover check completed; switched %d groups", changed) task.AppendLog(ctx, "%s", message) return &task.TaskResult{Message: message}, nil } diff --git a/internal/apps/openflare/config_version/errs.go b/internal/apps/openflare/config_version/errs.go index 44abb1f6..548fdb5f 100644 --- a/internal/apps/openflare/config_version/errs.go +++ b/internal/apps/openflare/config_version/errs.go @@ -5,9 +5,9 @@ package config_version const ( - errNoActiveVersion = "当前没有激活版本" - errNoEnabledRoutes = "没有可发布的启用规则" - errNoChangesToPublish = "当前规则没有变更,不能重复发布" - errVersionConflict = "版本号生成冲突,请重试" - errInvalidSnapshotFormat = "历史版本快照格式不合法" + errNoActiveVersion = "no active configuration version" + errNoEnabledRoutes = "no enabled rules are available to publish" + errNoChangesToPublish = "the current rules have not changed and cannot be published again" + errVersionConflict = "version identifier conflict; please try again" + errInvalidSnapshotFormat = "invalid historical version snapshot format" ) diff --git a/internal/apps/openflare/config_version/logics.go b/internal/apps/openflare/config_version/logics.go index 12badae0..ddb6e4e4 100644 --- a/internal/apps/openflare/config_version/logics.go +++ b/internal/apps/openflare/config_version/logics.go @@ -24,7 +24,7 @@ import ( ) const ( - cleanupSuccessMessage = "清理成功" + cleanupSuccessMessage = "Cleanup completed" minConfigVersionKeepCount = 3 ) diff --git a/internal/apps/openflare/config_version/pages_snapshot.go b/internal/apps/openflare/config_version/pages_snapshot.go index 2f270193..2bcb8a93 100644 --- a/internal/apps/openflare/config_version/pages_snapshot.go +++ b/internal/apps/openflare/config_version/pages_snapshot.go @@ -26,45 +26,45 @@ func buildPagesRouteSnapshot( route *model.ProxyRoute, ) (originURL string, upstreams []string, pagesProjectID *uint, deployment *openrestyrender.PagesDeployment, err error) { if route == nil { - return "", nil, nil, nil, errors.New("pages 路由配置无效") + return "", nil, nil, nil, errors.New("invalid Pages route configuration") } if !repository.HasPagesProjectsTable(ctx) { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 模块不可用", route.SiteName) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages module is unavailable", route.SiteName) } if route.PagesProjectID == nil || *route.PagesProjectID == 0 { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: 未绑定 Pages 项目", route.SiteName) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: no Pages project is bound", route.SiteName) } project, err := repository.GetPagesProjectByID(ctx, *route.PagesProjectID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", route.SiteName) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project was not found", route.SiteName) } return "", nil, nil, nil, err } if !project.Enabled { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", route.SiteName) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project is disabled", route.SiteName) } if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", route.SiteName) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project has no active deployment", route.SiteName) } activeDeployment, err := repository.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", route.SiteName) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment was not found", route.SiteName) } return "", nil, nil, nil, err } if activeDeployment.ProjectID != project.ID { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", route.SiteName) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment does not match", route.SiteName) } if strings.TrimSpace(activeDeployment.Checksum) == "" { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", route.SiteName) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages deployment checksum is missing", route.SiteName) } pagesProjectID = route.PagesProjectID deployment, err = buildSnapshotPagesDeployment(project, activeDeployment) if err != nil { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: %w", route.SiteName, err) + return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: %w", route.SiteName, err) } originURL = fmt.Sprintf("openflare-pages://project/%d", project.ID) return originURL, []string{originURL}, pagesProjectID, deployment, nil @@ -75,11 +75,11 @@ func buildSnapshotPagesDeployment( activeDeployment *model.PagesDeployment, ) (*openrestyrender.PagesDeployment, error) { if project == nil || activeDeployment == nil { - return nil, errors.New("pages 项目或部署为空") + return nil, errors.New("pages project or deployment is empty") } rootDir, err := pagesarchive.NormalizeLogicalPath(strings.TrimSpace(project.RootDir), true) if err != nil { - return nil, fmt.Errorf("pages 根目录不合法: %w", err) + return nil, fmt.Errorf("invalid Pages root directory: %w", err) } entryFile := strings.TrimSpace(project.EntryFile) if entryFile == "" { @@ -87,7 +87,7 @@ func buildSnapshotPagesDeployment( } entryFile, err = pagesarchive.NormalizeLogicalPath(entryFile, false) if err != nil { - return nil, fmt.Errorf("pages 入口文件不合法: %w", err) + return nil, fmt.Errorf("invalid Pages entry file: %w", err) } fallbackPath := strings.TrimSpace(project.SPAFallbackPath) if fallbackPath == "" { diff --git a/internal/apps/openflare/config_version/routers.go b/internal/apps/openflare/config_version/routers.go index 786fbc7d..915aba56 100644 --- a/internal/apps/openflare/config_version/routers.go +++ b/internal/apps/openflare/config_version/routers.go @@ -15,13 +15,13 @@ func handleLogicError(c *gin.Context, err error) bool { if err == nil { return false } - return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在") + return apiutil.AbortNotFoundIfMissing(c, err, "Record was not found") } func versionParam(c *gin.Context) (string, bool) { version := c.Param("id") if version == "" { - response.AbortBadRequest(c, "无效的版本号") + response.AbortBadRequest(c, "Invalid version identifier") return "", false } return version, true diff --git a/internal/apps/openflare/config_version/snapshot.go b/internal/apps/openflare/config_version/snapshot.go index f635c5bb..e742efc2 100644 --- a/internal/apps/openflare/config_version/snapshot.go +++ b/internal/apps/openflare/config_version/snapshot.go @@ -252,13 +252,13 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna } customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) if err != nil { - return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.SiteName) + return nil, fmt.Errorf("invalid custom request headers for route %s", route.SiteName) } upstreamType := normalizeUpstreamType(route.UpstreamType) originURL := route.OriginURL upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL) if err != nil { - return nil, fmt.Errorf("路由 %s 上游配置无效", route.SiteName) + return nil, fmt.Errorf("invalid upstream configuration for route %s", route.SiteName) } var tunnelNodeID *uint var tunnelTargetAddr string @@ -280,7 +280,7 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna } cacheRules, err := decodeStoredCacheRules(route.CacheRules) if err != nil { - return nil, fmt.Errorf("路由 %s 缓存规则无效", route.SiteName) + return nil, fmt.Errorf("invalid cache rules for route %s", route.SiteName) } items = append(items, snapshotRoute{ ID: route.ID, @@ -333,14 +333,14 @@ func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) ( } var editorGraph waf.RuleGraph if err = json.Unmarshal([]byte(group.Graph), &editorGraph); err != nil { - return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 的图数据无效: %w", group.Name, err) + return snapshotWAFDocument{}, fmt.Errorf("invalid graph data for WAF rule %s: %w", group.Name, err) } if err = waf.ValidateRuleGraph(ctx, editorGraph, snapshotWAFIPGroupExists); err != nil { - return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 的图无效: %w", group.Name, err) + return snapshotWAFDocument{}, fmt.Errorf("invalid graph for WAF rule %s: %w", group.Name, err) } runtimeGraph, compileErr := waf.CompileRuleGraph(editorGraph) if compileErr != nil { - return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 编译失败: %w", group.Name, compileErr) + return snapshotWAFDocument{}, fmt.Errorf("failed to compile WAF rule %s: %w", group.Name, compileErr) } ruleGroups = append(ruleGroups, snapshotWAFRuleGroup{ ID: group.ID, Name: group.Name, Enabled: group.Enabled, IsGlobal: group.IsGlobal, Graph: runtimeGraph, @@ -445,7 +445,7 @@ func buildSnapshotWAFIPGroups(ctx context.Context, idSet map[uint]struct{}) ([]s for _, id := range ids { group := groupByID[id] if group == nil { - return nil, fmt.Errorf("IP 组 %d 不存在", id) + return nil, fmt.Errorf("IP group %d was not found", id) } ipList, decodeErr := decodeIPList(group.IPList) if decodeErr != nil { diff --git a/internal/apps/openflare/config_version/waf_graph_snapshot_test.go b/internal/apps/openflare/config_version/waf_graph_snapshot_test.go index c97e6b23..db77c2cd 100644 --- a/internal/apps/openflare/config_version/waf_graph_snapshot_test.go +++ b/internal/apps/openflare/config_version/waf_graph_snapshot_test.go @@ -43,8 +43,8 @@ func TestBuildSnapshotRejectsOversizedAggregateWAFIPGroups(t *testing.T) { createSnapshotRule(t, ctx, "oversized-aggregate", snapshotIPMatchGraphForGroups(groupIDs)) _, err = buildSnapshotWAFDocument(ctx, nil) - require.ErrorContains(t, err, "WAF IP 组快照大小") - require.ErrorContains(t, err, "超过上限") + require.ErrorContains(t, err, "WAF IP group snapshot size") + require.ErrorContains(t, err, "exceeds the limit") } func TestWAFGraphSnapshotPreservesOrderAndGraphReferences(t *testing.T) { diff --git a/internal/apps/openflare/flared/errs.go b/internal/apps/openflare/flared/errs.go index 950db1f5..7de31541 100644 --- a/internal/apps/openflare/flared/errs.go +++ b/internal/apps/openflare/flared/errs.go @@ -5,6 +5,6 @@ package flared const ( - errTunnelTokenInvalid = "无权进行此操作,Tunnel Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errTunnelNodeTypeMismatch = "此节点不是 TunnelClient 类型" + errTunnelTokenInvalid = "Operation not authorized: invalid tunnel token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errTunnelNodeTypeMismatch = "This node is not a TunnelClient" ) diff --git a/internal/apps/openflare/flared/logics.go b/internal/apps/openflare/flared/logics.go index 3e7c3a87..c95aaacb 100644 --- a/internal/apps/openflare/flared/logics.go +++ b/internal/apps/openflare/flared/logics.go @@ -169,13 +169,13 @@ func ReportApplyLog(ctx context.Context, payload ApplyLogPayload) (*model.OpenFl now := time.Now().UTC() payload = normalizeApplyLogPayload(payload) if payload.NodeID == "" { - return nil, errors.New("node_id 不能为空") + return nil, errors.New("node_id must not be empty") } if payload.Version == "" { - return nil, errors.New("version 不能为空") + return nil, errors.New("version must not be empty") } if payload.Result != applyResultOK && payload.Result != applyResultWarn && payload.Result != applyResultFail { - return nil, errors.New("result 仅支持 success、warning 或 failed") + return nil, errors.New("result must be success, warning or failed") } latest, err := repository.GetLatestOpenFlareApplyLogByNodeID(ctx, payload.NodeID) diff --git a/internal/apps/openflare/geoip/lookup.go b/internal/apps/openflare/geoip/lookup.go index 3ce1f190..3f5c3cca 100644 --- a/internal/apps/openflare/geoip/lookup.go +++ b/internal/apps/openflare/geoip/lookup.go @@ -23,10 +23,10 @@ type LookupView struct { } const ( - errProviderInvalid = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo" - errIPEmpty = "IP 不能为空" - errIPInvalid = "IP 格式无效" - errLookupEmpty = "未获取到 IP 归属结果" + errProviderInvalid = "geolocation provider must be disabled, mmdb, ip-api, geojs or ipinfo" + errIPEmpty = "IP address must not be empty" + errIPInvalid = "invalid IP address format" + errLookupEmpty = "no IP geolocation result was returned" ) // IsValidProvider reports whether provider is a supported GeoIP backend. diff --git a/internal/apps/openflare/node/errs.go b/internal/apps/openflare/node/errs.go index ce337f57..d11392cc 100644 --- a/internal/apps/openflare/node/errs.go +++ b/internal/apps/openflare/node/errs.go @@ -7,18 +7,18 @@ package node const ( errInvalidNodeListQuery = "invalid node list parameters" errListNodesFailed = "Failed to load nodes" - errNodeNameRequired = "节点名不能为空" - errNodeIPTooLong = "节点 IP 不能超过 64 个字符" - errNodeIPInvalid = "节点 IP 格式无效" - errNodeIPManualRequired = "锁定节点 IP 时必须填写节点 IP" - errNodeGeoNameTooLong = "节点位置名不能超过 128 个字符" - errNodeGeoCoordinateMismatch = "地图坐标必须同时填写纬度和经度" - errNodeGeoLatitudeInvalid = "纬度必须在 -90 到 90 之间" - errNodeGeoLongitudeInvalid = "经度必须在 -180 到 180 之间" - errNodeIDConflict = "节点标识生成冲突,请重试" - errNodeNotFound = "节点不存在" - errNodeForceSyncFailed = "节点不在线或通过 WebSocket 发送同步指令失败" - errNoActiveConfigVersion = "当前没有激活版本" - errAgentPreviewTagInvalid = "指定版本不是 preview 发布" - errAgentStableTagInvalid = "正式版更新不能选择 preview 发布" + errNodeNameRequired = "node name must not be empty" + errNodeIPTooLong = "Node IP must not exceed 64 characters" + errNodeIPInvalid = "Invalid node IP address format" + errNodeIPManualRequired = "A node IP is required when IP locking is enabled" + errNodeGeoNameTooLong = "Node location name must not exceed 128 characters" + errNodeGeoCoordinateMismatch = "Both latitude and longitude are required" + errNodeGeoLatitudeInvalid = "Latitude must be between -90 and 90" + errNodeGeoLongitudeInvalid = "Longitude must be between -180 and 180" + errNodeIDConflict = "node identifier conflict; please try again" + errNodeNotFound = "node was not found" + errNodeForceSyncFailed = "node is offline or the synchronization command could not be sent over WebSocket" + errNoActiveConfigVersion = "no active configuration version" + errAgentPreviewTagInvalid = "the selected version is not a preview release" + errAgentStableTagInvalid = "a preview release cannot be selected for a stable update" ) diff --git a/internal/apps/openflare/node/helpers.go b/internal/apps/openflare/node/helpers.go index e3d5828b..496dfe00 100644 --- a/internal/apps/openflare/node/helpers.go +++ b/internal/apps/openflare/node/helpers.go @@ -332,15 +332,15 @@ func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubRe url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo)) req, err := newGitHubReleaseRequest(ctx, url) if err != nil { - return nil, errors.New("创建更新请求失败") + return nil, errors.New("failed to create update request") } resp, err := releaseHTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("获取最新版本失败: %w", err) + return nil, fmt.Errorf("failed to get latest version: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) + return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status) } return decodeGitHubRelease(resp.Body) } @@ -349,19 +349,19 @@ func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubR url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo)) req, err := newGitHubReleaseRequest(ctx, url) if err != nil { - return nil, errors.New("创建更新请求失败") + return nil, errors.New("failed to create update request") } resp, err := releaseHTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("获取 preview 版本失败: %w", err) + return nil, fmt.Errorf("failed to get preview version: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) + return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status) } var releases []githubReleaseResponse if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil { - return nil, errors.New("解析 preview 版本信息失败") + return nil, errors.New("failed to parse preview version information") } for _, release := range releases { if release.Draft || !release.Prerelease { @@ -370,29 +370,29 @@ func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubR releaseCopy := release return &releaseCopy, nil } - return nil, errors.New("当前没有可用的 preview 发布") + return nil, errors.New("no preview release is currently available") } func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) { tag = strings.TrimSpace(tag) if tag == "" { - return nil, errors.New("缺少发布版本号") + return nil, errors.New("missing release version identifier") } url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag) req, err := newGitHubReleaseRequest(ctx, url) if err != nil { - return nil, errors.New("创建更新请求失败") + return nil, errors.New("failed to create update request") } resp, err := releaseHTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("获取指定版本失败: %w", err) + return nil, fmt.Errorf("failed to get specified version: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("未找到指定版本: %s", tag) + return nil, fmt.Errorf("specified version was not found: %s", tag) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) + return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status) } return decodeGitHubRelease(resp.Body) } @@ -410,7 +410,7 @@ func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, er func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) { var release githubReleaseResponse if err := json.NewDecoder(reader).Decode(&release); err != nil { - return nil, errors.New("解析版本信息失败") + return nil, errors.New("failed to parse version information") } return &release, nil } diff --git a/internal/apps/openflare/node/logics.go b/internal/apps/openflare/node/logics.go index 4d76ab75..0256f029 100644 --- a/internal/apps/openflare/node/logics.go +++ b/internal/apps/openflare/node/logics.go @@ -363,9 +363,9 @@ func RequestForceSync(ctx context.Context, id uint) (*View, error) { activeConfig, err := repository.GetActiveConfigVersion(ctx) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("无法获取当前激活的配置版本:%s", errNoActiveConfigVersion) + return nil, fmt.Errorf("unable to get the currently active configuration version: %s", errNoActiveConfigVersion) } - return nil, fmt.Errorf("无法获取当前激活的配置版本:%w", err) + return nil, fmt.Errorf("unable to get the currently active configuration version: %w", err) } if !ofws.SendForceSyncConfig(node.NodeID, forceSyncConfigPayload{ Version: activeConfig.Version, @@ -418,14 +418,14 @@ type forceSyncConfigPayload struct { func ValidateDiscoveryToken(ctx context.Context, token string) error { token = strings.TrimSpace(token) if token == "" { - return errors.New("缺少 Discovery Token") + return errors.New("missing discovery token") } discoveryToken, err := ensureGlobalDiscoveryToken(ctx) if err != nil { return err } if !tokenEqual(token, discoveryToken) { - return errors.New("discovery Token 无效") // error 消息首字母小写 + return errors.New("invalid discovery token") // error 消息首字母小写 } return nil } diff --git a/internal/apps/openflare/node/routers.go b/internal/apps/openflare/node/routers.go index e5fe4fec..4288ffbf 100644 --- a/internal/apps/openflare/node/routers.go +++ b/internal/apps/openflare/node/routers.go @@ -203,7 +203,7 @@ func RequestAgentUpdateHandler(c *gin.Context) { var request AgentUpdateInput if c.Request.ContentLength > 0 { if err := bindOptionalJSON(c.Request.Body, &request); err != nil { - response.AbortBadRequest(c, "参数错误") + response.AbortBadRequest(c, "invalid parameters") return } } @@ -283,7 +283,7 @@ func GetObservabilityHandler(c *gin.Context) { } var query ObservabilityQuery if err := c.ShouldBindQuery(&query); err != nil { - response.AbortBadRequest(c, "参数错误") + response.AbortBadRequest(c, "invalid parameters") return } view, err := GetObservability(c.Request.Context(), id, query) diff --git a/internal/apps/openflare/observability/access_log_logics.go b/internal/apps/openflare/observability/access_log_logics.go index be9b2c8e..b7549daa 100644 --- a/internal/apps/openflare/observability/access_log_logics.go +++ b/internal/apps/openflare/observability/access_log_logics.go @@ -860,7 +860,7 @@ func limitDistributionItems(items []DistributionItem, limit int) []DistributionI func normalizeAccessLogIPAnalysisQuery(input AccessLogIPAnalysisQuery) (AccessLogIPAnalysisQuery, error) { remoteAddr := strings.TrimSpace(input.RemoteAddr) if remoteAddr == "" { - return AccessLogIPAnalysisQuery{}, errors.New("remote_addr 不能为空") + return AccessLogIPAnalysisQuery{}, errors.New("remote_addr must not be empty") } hours := input.Hours if hours <= 0 { @@ -880,7 +880,7 @@ func normalizeAccessLogIPAnalysisQuery(input AccessLogIPAnalysisQuery) (AccessLo // CleanupAccessLogs removes access logs older than retention days. func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*AccessLogCleanupResult, error) { if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays { - return nil, errors.New("retention_days 必须在 1 到 90 之间") + return nil, errors.New("retention_days must be between 1 and 90") } cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour) deleted, err := repository.DeleteOpenFlareAccessLogsBefore(ctx, cutoff) @@ -923,18 +923,18 @@ func buildModelAccessLogQuery(input AccessLogQuery) (model.OpenFlareAccessLogQue // 且 until 需晚于 since。 func resolveAccessLogWindow(sinceRaw, untilRaw string) (time.Time, time.Time, error) { if sinceRaw == "" || untilRaw == "" { - return time.Time{}, time.Time{}, errors.New("since 与 until 需同时提供") + return time.Time{}, time.Time{}, errors.New("since and until must be provided together") } parsedSince, err := time.Parse(time.RFC3339, sinceRaw) if err != nil { - return time.Time{}, time.Time{}, errors.New("since 时间格式无效,需为 RFC3339") + return time.Time{}, time.Time{}, errors.New("invalid since timestamp; RFC3339 is required") } parsedUntil, err := time.Parse(time.RFC3339, untilRaw) if err != nil { - return time.Time{}, time.Time{}, errors.New("until 时间格式无效,需为 RFC3339") + return time.Time{}, time.Time{}, errors.New("invalid until timestamp; RFC3339 is required") } if !parsedUntil.After(parsedSince) { - return time.Time{}, time.Time{}, errors.New("until 必须晚于 since") + return time.Time{}, time.Time{}, errors.New("until must be later than since") } return parsedSince, parsedUntil, nil } @@ -1021,23 +1021,23 @@ func resolveAccessLogIPSummaryWindow(sinceRaw, untilRaw string, hours int) (time return until.Add(-time.Duration(hours) * time.Hour), until, hours, nil } if sinceRaw == "" || untilRaw == "" { - return time.Time{}, time.Time{}, 0, errors.New("since 与 until 需同时提供") + return time.Time{}, time.Time{}, 0, errors.New("since and until must be provided together") } parsedSince, err := time.Parse(time.RFC3339, sinceRaw) if err != nil { - return time.Time{}, time.Time{}, 0, errors.New("since 必须为 RFC3339 时间") + return time.Time{}, time.Time{}, 0, errors.New("since must be an RFC3339 timestamp") } parsedUntil, err := time.Parse(time.RFC3339, untilRaw) if err != nil { - return time.Time{}, time.Time{}, 0, errors.New("until 必须为 RFC3339 时间") + return time.Time{}, time.Time{}, 0, errors.New("until must be an RFC3339 timestamp") } since := parsedSince.UTC() until := parsedUntil.UTC() if !until.After(since) { - return time.Time{}, time.Time{}, 0, errors.New("until 必须晚于 since") + return time.Time{}, time.Time{}, 0, errors.New("until must be later than since") } if until.Sub(since) > time.Duration(maxAccessLogOverviewHours)*time.Hour { - return time.Time{}, time.Time{}, 0, errors.New("时间范围不能超过 30 天") + return time.Time{}, time.Time{}, 0, errors.New("time range must not exceed 30 days") } hours = int(until.Sub(since).Hours()) if hours <= 0 { @@ -1053,7 +1053,7 @@ func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccess } bucketStartedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(input.BucketStartedAt)) if err != nil { - return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at 必须为 RFC3339 时间") + return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at must be an RFC3339 timestamp") } normalizedSortBy := strings.TrimSpace(input.SortBy) switch normalizedSortBy { @@ -1078,7 +1078,7 @@ func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccess func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) { remoteAddr := strings.TrimSpace(input.RemoteAddr) if remoteAddr == "" { - return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空") + return AccessLogIPTrendQuery{}, errors.New("remote_addr must not be empty") } hours := input.Hours if hours <= 0 { @@ -1094,7 +1094,7 @@ func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTre switch bucketMinutes { case 5, 10, 15, 30, 60: default: - return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60") + return AccessLogIPTrendQuery{}, errors.New("bucket_minutes must be 5, 10, 15, 30 or 60") } return AccessLogIPTrendQuery{ NodeID: strings.TrimSpace(input.NodeID), @@ -1166,7 +1166,7 @@ func normalizeFoldMinutes(value int) (int, error) { case 3, 5: return value, nil default: - return 0, errors.New("fold_minutes 仅支持 3 或 5") + return 0, errors.New("fold_minutes must be 3 or 5") } } diff --git a/internal/apps/openflare/observability/errs.go b/internal/apps/openflare/observability/errs.go index 5213d1a5..5daa86ba 100644 --- a/internal/apps/openflare/observability/errs.go +++ b/internal/apps/openflare/observability/errs.go @@ -5,5 +5,5 @@ package observability const ( - errInvalidStatusCode = "status_code 必须为 100-599 之间的整数" + errInvalidStatusCode = "status_code must be an integer between 100 and 599" ) diff --git a/internal/apps/openflare/option/errs.go b/internal/apps/openflare/option/errs.go index 0b1f7f0a..6e9ea607 100644 --- a/internal/apps/openflare/option/errs.go +++ b/internal/apps/openflare/option/errs.go @@ -5,10 +5,10 @@ package option const ( - errInvalidParams = "无效的参数" - errOptionInitFailed = "系统选项初始化失败" - errGeoIPProvider = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo" - errGeoIPIPEmpty = "IP 不能为空" - errGeoIPIPInvalid = "IP 格式无效" - errGeoIPLookupDisabled = "GeoIP 查询已禁用" + errInvalidParams = "invalid parameters" + errOptionInitFailed = "Failed to initialize system options" + errGeoIPProvider = "geolocation provider must be disabled, mmdb, ip-api, geojs or ipinfo" + errGeoIPIPEmpty = "IP address must not be empty" + errGeoIPIPInvalid = "invalid IP address format" + errGeoIPLookupDisabled = "GeoIP lookup is disabled" ) diff --git a/internal/apps/openflare/option/openresty_validators.go b/internal/apps/openflare/option/openresty_validators.go index 5ed3524f..24e15b18 100644 --- a/internal/apps/openflare/option/openresty_validators.go +++ b/internal/apps/openflare/option/openresty_validators.go @@ -93,7 +93,7 @@ func validateOpenRestyDefaultServerReturnStatus(key, trimmed string) error { } statusCode, _ := strconv.Atoi(trimmed) if statusCode < 100 || statusCode > 999 { - return fmt.Errorf("%s 必须在 100 到 999 之间", key) + return fmt.Errorf("%s must be between 100 and 999", key) } return nil } @@ -111,7 +111,7 @@ func validateOpenRestyGzipCompLevel(key, trimmed string) error { } level, _ := strconv.Atoi(trimmed) if level > maxOpenRestyGzipCompLevel { - return fmt.Errorf("%s 不能大于 %d", key, maxOpenRestyGzipCompLevel) + return fmt.Errorf("%s must not exceed %d", key, maxOpenRestyGzipCompLevel) } return nil } @@ -124,7 +124,7 @@ func validateOpenRestyEventsUse(key, trimmed string) error { case "epoll", "kqueue", "poll", "select", "rtsig", "/dev/poll", "eventport": return nil default: - return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key) + return fmt.Errorf("%s must be epoll, kqueue, poll, select, rtsig, /dev/poll, eventport or empty", key) } } @@ -133,7 +133,7 @@ func validateOpenRestyResolvers(key, trimmed string) error { return nil } if !regexp.MustCompile(`^[a-zA-Z0-9.:\-\s]+$`).MatchString(trimmed) { - return fmt.Errorf("%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", key) + return fmt.Errorf("%s contains invalid characters; enter valid IP addresses or domain names separated by spaces", key) } return nil } @@ -142,19 +142,19 @@ func validateOpenRestyProxyBuffers(key, trimmed string) error { if openRestyProxyBuffersPattern.MatchString(trimmed) { return nil } - return fmt.Errorf("%s 格式必须类似 \"16 16k\"", key) + return fmt.Errorf("%s must use a format such as \\\"16 16k\\\"", key) } func validateOpenRestySizeValue(key, trimmed string) error { if openRestySizePattern.MatchString(trimmed) { return nil } - return fmt.Errorf("%s 格式必须为整数或带 k/m/g 单位的大小值", key) + return fmt.Errorf("%s must be an integer or a size with a k/m/g suffix", key) } func validateOpenRestyCachePath(key, trimmed string) error { if strings.ContainsAny(trimmed, "\r\n\t") { - return fmt.Errorf("%s 不能包含换行或制表符", key) + return fmt.Errorf("%s must not contain newlines or tabs", key) } return nil } @@ -163,29 +163,29 @@ func validateOpenRestyCacheLevels(key, trimmed string) error { if openRestyCacheLevelsPattern.MatchString(trimmed) { return nil } - return fmt.Errorf("%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", key) + return fmt.Errorf("%s must use a format such as \\\"1:2\\\" or \\\"1:2:2\\\"", key) } func validateOpenRestyDurationToken(key, trimmed string) error { if openRestyDurationTokenPattern.MatchString(trimmed) { return nil } - return fmt.Errorf("%s 格式必须为带单位的时长,例如 30m 或 5s", key) + return fmt.Errorf("%s must be a duration with a unit, such as 30m or 5s", key) } func validateOpenRestyCacheKeyTemplate(key, trimmed string) error { if trimmed == "" { - return fmt.Errorf("%s 不能为空", key) + return fmt.Errorf("%s must not be empty", key) } if strings.ContainsAny(trimmed, "\r\n") { - return fmt.Errorf("%s 不能包含换行", key) + return fmt.Errorf("%s must not contain newlines", key) } return nil } func validateOpenRestyCacheUseStale(key, trimmed string) error { if trimmed == "" { - return fmt.Errorf("%s 不能为空", key) + return fmt.Errorf("%s must not be empty", key) } allowedTokens := map[string]struct{}{ "error": {}, "timeout": {}, "invalid_header": {}, "updating": {}, @@ -194,7 +194,7 @@ func validateOpenRestyCacheUseStale(key, trimmed string) error { } for token := range strings.FieldsSeq(trimmed) { if _, ok := allowedTokens[token]; !ok { - return fmt.Errorf("%s 包含不支持的值 %q", key, token) + return fmt.Errorf("%s contains unsupported value %q", key, token) } } return nil @@ -202,7 +202,7 @@ func validateOpenRestyCacheUseStale(key, trimmed string) error { func validateOpenRestyMainConfigTemplate(key, value string) error { if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s 不能为空", key) + return fmt.Errorf("%s must not be empty", key) } return nil } @@ -210,15 +210,15 @@ func validateOpenRestyMainConfigTemplate(key, value string) error { func validateOpenRestyTrustedProxyCIDRs(key, value string) error { var cidrs []string if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil { - return fmt.Errorf("%s 必须为 JSON 字符串数组", key) + return fmt.Errorf("%s must be a JSON array of strings", key) } if len(cidrs) > maxTrustedProxyCIDRs { - return fmt.Errorf("%s 最多允许 %d 个网段", key, maxTrustedProxyCIDRs) + return fmt.Errorf("%s must contain at most %d network ranges", key, maxTrustedProxyCIDRs) } for _, cidr := range cidrs { prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr)) if err != nil || prefix != prefix.Masked() { - return fmt.Errorf("%s 包含无效网段 %q", key, cidr) + return fmt.Errorf("%s contains invalid network range %q", key, cidr) } } return nil @@ -229,7 +229,7 @@ func validateOpenRestyDefaultLimitRate(key, trimmed string) error { return nil } if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) { - return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key) + return fmt.Errorf("invalid %s format; use 512k, 1m or a number, or leave empty to disable", key) } return nil } @@ -241,35 +241,35 @@ func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error { return nil } if !openRestyDefaultLimitReqPerIPPattern.MatchString(strings.ToLower(trimmed)) { - return fmt.Errorf("%s 格式不合法,请输入类似 10r/s、100r/m,或留空关闭", key) + return fmt.Errorf("invalid %s format; use a rate such as 10r/s or 100r/m, or leave empty to disable", key) } return nil } func validateOriginErrorPageStatusCodes(key, trimmed string) error { if trimmed == "" { - return fmt.Errorf("%s 不能为空", key) + return fmt.Errorf("%s must not be empty", key) } var tags []string if err := json.Unmarshal([]byte(trimmed), &tags); err != nil { - return fmt.Errorf("%s 必须为 JSON 字符串数组", key) + return fmt.Errorf("%s must be a JSON array of strings", key) } if len(tags) == 0 { - return fmt.Errorf("%s 至少包含一个状态码标签", key) + return fmt.Errorf("%s must contain at least one status code tag", key) } codes, err := openrestyrender.ExpandStatusCodeTags(tags) if err != nil { return fmt.Errorf("%s: %w", key, err) } if len(codes) == 0 { - return fmt.Errorf("%s 展开后不能为空", key) + return fmt.Errorf("%s must not be empty after expansion", key) } return nil } func validateOriginErrorPageHTML(key, value string) error { if len(value) > maxOriginErrorPageHTMLBytes { - return fmt.Errorf("%s 长度不能超过 %d 字节(256 KiB)", key, maxOriginErrorPageHTMLBytes) + return fmt.Errorf("%s must not exceed %d bytes (256 KiB)", key, maxOriginErrorPageHTMLBytes) } return nil } @@ -281,22 +281,22 @@ func validateSWOfflineHTML(key, value string) error { func validateSWOfflineDomains(key, value string) error { var domains []string if err := json.Unmarshal([]byte(value), &domains); err != nil || domains == nil { - return fmt.Errorf("%s 必须为 JSON 字符串数组", key) + return fmt.Errorf("%s must be a JSON array of strings", key) } if len(domains) > maxSWOfflineDomains { - return fmt.Errorf("%s 最多支持 %d 个域名", key, maxSWOfflineDomains) + return fmt.Errorf("%s supports at most %d domain names", key, maxSWOfflineDomains) } seen := make(map[string]struct{}, len(domains)) for _, raw := range domains { domain := strings.ToLower(strings.TrimSpace(raw)) if domain == "" { - return fmt.Errorf("%s 包含空域名", key) + return fmt.Errorf("%s contains an empty domain name", key) } if raw != domain { - return fmt.Errorf("%s 域名必须为小写且不含首尾空格:%s", key, raw) + return fmt.Errorf("%s domain name must be lowercase without surrounding whitespace: %s", key, raw) } if _, ok := seen[domain]; ok { - return fmt.Errorf("%s 包含重复域名 %s", key, domain) + return fmt.Errorf("%s contains duplicate domain name %s", key, domain) } seen[domain] = struct{}{} } diff --git a/internal/apps/openflare/option/openresty_validators_test.go b/internal/apps/openflare/option/openresty_validators_test.go index a27ca4a7..11e99071 100644 --- a/internal/apps/openflare/option/openresty_validators_test.go +++ b/internal/apps/openflare/option/openresty_validators_test.go @@ -32,27 +32,27 @@ func TestValidateOriginErrorPageStatusCodes(t *testing.T) { { name: "非法标签", value: `["abc"]`, - wantErr: "无效状态码", + wantErr: "invalid status code", }, { name: "非 JSON 数组", value: `500-599`, - wantErr: "必须为 JSON 字符串数组", + wantErr: "must be a JSON array of strings", }, { name: "空数组", value: `[]`, - wantErr: "至少包含一个状态码标签", + wantErr: "at least one status code tag", }, { name: "越界状态码", value: `["399"]`, - wantErr: "状态码须在", + wantErr: "status code must be between", }, { name: "空字符串", value: "", - wantErr: "不能为空", + wantErr: "must not be empty", }, } @@ -87,7 +87,7 @@ func TestValidateOriginErrorPageHTML(t *testing.T) { oversized := strings.Repeat("a", maxOriginErrorPageHTMLBytes+1) err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, oversized) require.Error(t, err) - assert.Contains(t, err.Error(), "长度不能超过") + assert.Contains(t, err.Error(), "must not exceed") // 恰好上限应通过 atLimit := strings.Repeat("b", maxOriginErrorPageHTMLBytes) @@ -101,7 +101,7 @@ func TestValidateOriginErrorPageEnabled(t *testing.T) { require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "false")) err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "yes") require.Error(t, err) - assert.Contains(t, err.Error(), "true 或 false") + assert.Contains(t, err.Error(), "true or false") } func TestValidateSWOfflineDomains(t *testing.T) { diff --git a/internal/apps/openflare/option/routers.go b/internal/apps/openflare/option/routers.go index 349e7a96..f46228e7 100644 --- a/internal/apps/openflare/option/routers.go +++ b/internal/apps/openflare/option/routers.go @@ -139,5 +139,5 @@ func SyncUptimeKumaHandler(c *gin.Context) { if apiutil.AbortBadRequestOnError(c, syncUptimeKuma(c.Request.Context())) { return } - c.JSON(http.StatusOK, response.OK("同步成功")) + c.JSON(http.StatusOK, response.OK("Synchronization succeeded")) } diff --git a/internal/apps/openflare/option/validate.go b/internal/apps/openflare/option/validate.go index f48c2b15..17896008 100644 --- a/internal/apps/openflare/option/validate.go +++ b/internal/apps/openflare/option/validate.go @@ -28,7 +28,7 @@ var ( const optionValueTrue = "true" // protectedConfigKeyMessage 命中受保护 key 时返回给管理员的业务错误文案。 -const protectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改" +const protectedConfigKeyMessage = "this configuration is managed by system tasks and cannot be changed manually" // protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。 var protectedConfigKeys = map[string]bool{ @@ -79,7 +79,7 @@ func validateOptionWithState(ctx context.Context, option model.OpenFlareOption, func validatePositiveIntegerOption(key, value string) error { intValue, err := strconv.Atoi(value) if err != nil || intValue <= 0 { - return fmt.Errorf("%s 必须为大于 0 的整数", key) + return fmt.Errorf("%s must be an integer greater than 0", key) } return nil } @@ -87,7 +87,7 @@ func validatePositiveIntegerOption(key, value string) error { func validateNonNegativeIntegerOption(key, value string) error { intValue, err := strconv.Atoi(value) if err != nil || intValue < 0 { - return fmt.Errorf("%s 必须为大于等于 0 的整数", key) + return fmt.Errorf("%s must be a nonnegative integer", key) } return nil } @@ -97,7 +97,7 @@ func validateBooleanOption(key, value string) error { case optionValueTrue, "false": return nil default: - return fmt.Errorf("%s 必须为 true 或 false", key) + return fmt.Errorf("%s must be true or false", key) } } @@ -108,7 +108,7 @@ func validateGeoIPOption(key, value string) error { if geoip.IsValidProvider(value) { return nil } - return fmt.Errorf("%s 仅支持 disabled、mmdb、ip-api、geojs、ipinfo", key) + return fmt.Errorf("%s must be disabled, mmdb, ip-api, geojs or ipinfo", key) } func validateLogRetentionOption(key, value string) error { @@ -116,7 +116,7 @@ func validateLogRetentionOption(key, value string) error { case model.ConfigKeyLogRetentionDaysPostgres, model.ConfigKeyLogRetentionDaysSQLite, model.ConfigKeyLogRetentionDaysClickHouse: intValue, err := strconv.Atoi(value) if err != nil || intValue < 1 { - return fmt.Errorf("%s 必须为大于等于 1 的整数天", key) + return fmt.Errorf("%s must be a whole number of days greater than or equal to 1", key) } } return nil @@ -135,12 +135,12 @@ func validatePagesOption(key, value string) error { case model.ConfigKeyPagesMaxPackageSizeMB: intValue, err := strconv.Atoi(trimmed) if err != nil || intValue < 1 || intValue > 2048 { - return fmt.Errorf("%s 必须为 1~2048 的整数(MiB)", key) + return fmt.Errorf("%s must be an integer between 1 and 2048 (MiB)", key) } case model.ConfigKeyPagesMaxHistoryCount: intValue, err := strconv.Atoi(trimmed) if err != nil || intValue < 0 { - return fmt.Errorf("%s 必须为大于等于 0 的整数(0 表示不限制)", key) + return fmt.Errorf("%s must be a nonnegative integer (0 means unlimited)", key) } } return nil @@ -176,16 +176,16 @@ func validateUptimeKumaEnabled(ctx context.Context, key, trimmed string, state m username := strings.TrimSpace(state[model.ConfigKeyUptimeKumaUsername]) password := strings.TrimSpace(state[model.ConfigKeyUptimeKumaPassword]) if url == "" { - return errors.New("启用 Uptime Kuma 时地址不能为空") + return errors.New("uptime Kuma address is required when the integration is enabled") } if username == "" { - return errors.New("启用 Uptime Kuma 时用户名不能为空") + return errors.New("uptime Kuma username is required when the integration is enabled") } // 如果待验证的密码为空,且当前配置中也没有密码,则报错 if password == "" { existingPwd, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUptimeKumaPassword) if strings.TrimSpace(existingPwd.Value) == "" { - return errors.New("启用 Uptime Kuma 时密码不能为空") + return errors.New("uptime Kuma password is required when the integration is enabled") } } return nil @@ -193,21 +193,21 @@ func validateUptimeKumaEnabled(ctx context.Context, key, trimmed string, state m func validateUptimeKumaUsername(trimmed string, state map[string]string) error { if trimmed == "" && state[model.ConfigKeyUptimeKumaEnabled] == optionValueTrue { - return errors.New("启用 Uptime Kuma 时用户名不能为空") + return errors.New("uptime Kuma username is required when the integration is enabled") } return nil } func validateUptimeKumaURL(trimmed string) error { if trimmed != "" && !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") { - return errors.New("uptime Kuma 地址必须以 http:// 或 https:// 开头") + return errors.New("uptime Kuma address must start with http:// or https://") } return nil } func validateUptimeKumaMonitorScope(trimmed string) error { if trimmed != "all" && trimmed != "selected" { - return errors.New("监控范围必须为全部站点 (all) 或选择站点 (selected)") + return errors.New("monitor scope must be all or selected sites") } return nil } @@ -215,7 +215,7 @@ func validateUptimeKumaMonitorScope(trimmed string) error { func validateUptimeKumaRetry(key, trimmed string) error { intValue, err := strconv.Atoi(trimmed) if err != nil || intValue < 0 { - return fmt.Errorf("%s 必须为大于等于 0 的整数", key) + return fmt.Errorf("%s must be a nonnegative integer", key) } return nil } diff --git a/internal/apps/openflare/origin/errs.go b/internal/apps/openflare/origin/errs.go index 630414ff..f957eaec 100644 --- a/internal/apps/openflare/origin/errs.go +++ b/internal/apps/openflare/origin/errs.go @@ -5,10 +5,10 @@ package origin const ( - errOriginAddressRequired = "源站地址不能为空" - errOriginAddressInvalid = "源站地址格式不合法" - errOriginAddressExists = "源站地址已存在" - errOriginDeleteReferenced = "该源站仍被规则引用,无法删除" - errOriginMissingPort = "源站地址缺少端口" - errOriginNotFound = "源站不存在" + errOriginAddressRequired = "origin address must not be empty" + errOriginAddressInvalid = "invalid origin address format" + errOriginAddressExists = "origin address already exists" + errOriginDeleteReferenced = "this origin is still referenced by rules and cannot be deleted" + errOriginMissingPort = "origin address is missing a port" + errOriginNotFound = "Origin was not found" ) diff --git a/internal/apps/openflare/pages/download_url_test.go b/internal/apps/openflare/pages/download_url_test.go index fc46e65f..1299d371 100644 --- a/internal/apps/openflare/pages/download_url_test.go +++ b/internal/apps/openflare/pages/download_url_test.go @@ -19,7 +19,7 @@ import ( func TestParseAndValidatePagesDownloadURL(t *testing.T) { _, err := parseAndValidatePagesDownloadURL("") require.Error(t, err) - assert.Contains(t, err.Error(), "填写") + assert.Contains(t, err.Error(), "enter") _, err = parseAndValidatePagesDownloadURL("ftp://example.com/a.zip") require.Error(t, err) diff --git a/internal/apps/openflare/pages/errs.go b/internal/apps/openflare/pages/errs.go index a6a9a19a..4fd9f41c 100644 --- a/internal/apps/openflare/pages/errs.go +++ b/internal/apps/openflare/pages/errs.go @@ -5,62 +5,62 @@ package pages const ( - errPagesProjectNotFound = "pages 项目不存在" - errPagesSlugExists = "pages 项目标识已存在" - errPagesNameRequired = "pages 项目名称不能为空" - errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符" - errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除" - errPagesDeploymentNotFound = "pages 部署不存在" - errPagesDeploymentMismatch = "pages 部署不属于该项目" - errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署" - errPagesPackageMissing = "缺少 Pages 部署包" - errPagesPackageURLRequired = "请填写部署包下载链接" - errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https" - errPagesPackageURLDownloadFailed = "从链接下载部署包失败" - errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制" - errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests - errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式" - errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias - errPagesPackageInvalid = "pages 部署包不是有效的压缩文件" - errPagesPackageEmpty = "pages 部署包不能为空" - errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制" - errPagesPackageFileTooLarge = "pages 部署包内文件过大" - errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空" - errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头" - errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errPagesPackagePathEmpty = "pages 部署包路径为空" - errPagesPackageUploadMissing = "pages 部署包上传记录不存在" - errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置" - errPagesDeploymentHashMissing = "pages 部署包哈希缺失" - errPagesInvalidSnapshotFormat = "配置快照格式无效" - errPagesActorMissing = "无法识别当前用户" - errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件" - errPagesSourceNotFound = "pages 部署源不存在" - errPagesSourceTypeRequired = "请选择 pages 部署源类型" - errPagesSourceTypeUnsupported = "pages 部署源类型不受支持" - errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置" - errPagesSourceRemoteURLRequired = "请提供远程部署包地址" - errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址" - errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置" - errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}" - errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效" - errPagesSourceAssetNameInvalid = "GitHub Release 资源名称必须是安全的文件名" - errPagesSourceCheckInterval = "GitHub latest 检查间隔必须在 5 到 1440 分钟之间" - errPagesSourceAutoNotAvailable = "自动更新将在后续阶段开放,当前必须保持关闭" - errPagesSourceReleaseNotFound = "未找到符合配置的 GitHub Release 资源" - errPagesSourceDigestInvalid = "GitHub Release 资源摘要格式无效" - errPagesSourceDigestMismatch = "GitHub Release 资源摘要校验失败" - errPagesSourceConfirmationNeeded = "检测到同一 Release 的资源已被替换,请刷新并确认当前版本" - errPagesSourceConfirmationStale = "确认的版本已变化,请刷新后重新确认" - errPagesSourceInitialCheckWarning = "部署源已保存,但首次检查任务入队失败,请稍后手动检查" - errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步" - errPagesSourceActionBusy = "pages 部署源任务正在执行" - errPagesSourceActionInvalid = "pages 部署源任务参数无效" - errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过" - errPagesSourceLeaseLost = "pages 部署源任务执行权已失效" - errPagesSourceLeaseExpired = "上次 pages 部署源任务租约已过期" - errPagesSourceSyncFailed = "pages 部署源同步失败" - errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败" - errPagesSourceInternal = "pages 部署源操作失败,请稍后重试" + errPagesProjectNotFound = "pages project was not found" + errPagesSlugExists = "pages project slug already exists" + errPagesNameRequired = "pages project name must not be empty" + errPagesSlugInvalid = "pages project slug may only contain lowercase letters, digits and hyphens" + errPagesDeleteReferenced = "pages project is referenced by rules and cannot be deleted" + errPagesDeploymentNotFound = "pages deployment was not found" + errPagesDeploymentMismatch = "pages deployment does not belong to this project" + errPagesDeleteActiveDeploy = "the active Pages deployment cannot be deleted" + errPagesPackageMissing = "missing Pages deployment archive" + errPagesPackageURLRequired = "enter the deployment archive download URL" + errPagesPackageURLInvalid = "invalid deployment archive URL; only HTTP and HTTPS are supported" + errPagesPackageURLDownloadFailed = "Failed to download deployment archive from URL" + errPagesPackageURLTooLarge = "Deployment archive at the URL exceeds the size limit" + errPagesPackageNotZip = "Pages deployment archive must be a .zip file" // legacy alias kept for tests + errPagesPackageUnsupported = "pages archives support zip, tar.gz, tar.xz, tar.bz2, tar and 7z formats only" + errPagesPackageInvalidZip = "Pages deployment archive is not a valid ZIP file" // legacy alias + errPagesPackageInvalid = "pages deployment archive is not a valid compressed archive" + errPagesPackageEmpty = "pages deployment archive must not be empty" + errPagesPackageExtractedTooLarge = "extracted Pages deployment exceeds the size limit" + errPagesPackageFileTooLarge = "a file in the Pages deployment archive exceeds the size limit" + errPagesAPIProxyPathRequired = "match path is required when the API reverse proxy is enabled" + errPagesAPIProxyPathPrefix = "API reverse proxy match path must start with '/'" + errPagesAPIProxyPassRequired = "backend URL is required when the API reverse proxy is enabled" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errPagesAPIProxyPassInvalid = "API reverse proxy backend must be a valid HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errPagesPackagePathEmpty = "Pages deployment archive path is empty" + errPagesPackageUploadMissing = "pages deployment archive upload record was not found" + errPagesPackageNotInActiveConfig = "pages deployment is not included in the active configuration" + errPagesDeploymentHashMissing = "pages deployment archive hash is missing" + errPagesInvalidSnapshotFormat = "invalid configuration snapshot format" + errPagesActorMissing = "Unable to identify the current user" + errPagesEntryFileMissing = "The specified entry file does not exist in the active deployment" + errPagesSourceNotFound = "pages deployment source was not found" + errPagesSourceTypeRequired = "select a Pages deployment source type" + errPagesSourceTypeUnsupported = "unsupported Pages deployment source type" + errPagesSourceRemoteFields = "remote URL sources must not include GitHub or automatic update configuration" + errPagesSourceRemoteURLRequired = "provide the remote deployment archive URL" + errPagesSourceRemoteURLInvalid = "invalid remote archive URL; use HTTP/HTTPS without user information or fragments" + errPagesSourceGitHubFields = "GitHub Release sources must not include remote URL configuration" + errPagesSourceRepositoryInvalid = "invalid GitHub repository URL; use https://github.com/{owner}/{repo}" + errPagesSourceSelectorInvalid = "invalid GitHub Release selection mode" + errPagesSourceAssetNameInvalid = "GitHub Release asset name must be a safe filename" + errPagesSourceCheckInterval = "GitHub latest check interval must be between 5 and 1440 minutes" + errPagesSourceAutoNotAvailable = "Automatic updates will be available in a later stage and must remain disabled for now" + errPagesSourceReleaseNotFound = "no GitHub Release asset matches the configuration" + errPagesSourceDigestInvalid = "invalid GitHub Release asset digest format" + errPagesSourceDigestMismatch = "GitHub Release asset digest verification failed" + errPagesSourceConfirmationNeeded = "an asset in the same release was replaced; refresh and confirm the current version" + errPagesSourceConfirmationStale = "the confirmed version has changed; refresh and confirm again" + errPagesSourceInitialCheckWarning = "Deployment source saved, but the initial check could not be enqueued; run a manual check later" + errPagesSourceCheckUnsupported = "remote URL sources do not support update checks; use Sync now" + errPagesSourceActionBusy = "a Pages deployment source task is already running" + errPagesSourceActionInvalid = "invalid Pages deployment source task parameters" + errPagesSourceActionStale = "Pages deployment source configuration changed; task skipped" + errPagesSourceLeaseLost = "pages deployment source task ownership is no longer valid" + errPagesSourceLeaseExpired = "Previous Pages deployment source task lease expired" + errPagesSourceSyncFailed = "pages deployment source synchronization failed" + errPagesSourceTaskDispatchFailed = "failed to enqueue Pages deployment source task" + errPagesSourceInternal = "Pages deployment source operation failed; please try again later" ) diff --git a/internal/apps/openflare/pages/github_source_action.go b/internal/apps/openflare/pages/github_source_action.go index 755019c9..08baf6f6 100644 --- a/internal/apps/openflare/pages/github_source_action.go +++ b/internal/apps/openflare/pages/github_source_action.go @@ -80,7 +80,7 @@ func checkGitHubSource( if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease { return nil, errors.New(errPagesSourceTypeUnsupported) } - task.AppendLog(ctx, "[check] 正在检查 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName) + task.AppendLog(ctx, "[check] Checking GitHub Release: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName) client := newGitHubReleaseClient() result, err := client.Resolve(ctx, githubrelease.ResolveRequest{ Repository: snapshot.GitHubRepository, @@ -111,7 +111,7 @@ func checkGitHubSource( } detail, _ := json.Marshal(map[string]string{"revision": revision, pagesDeploymentColumnStatus: status}) return &githubCheckTaskResult{ - Message: "GitHub Release 检查完成,内容未变化", + Message: "GitHub Release check completed; content is unchanged", Detail: string(detail), Revision: revision, Status: status, @@ -140,12 +140,12 @@ func checkGitHubSource( return nil, err } detail, _ := json.Marshal(map[string]string{"revision": target.Revision, pagesDeploymentColumnStatus: status}) - message := "GitHub Release 检查完成" + message := "GitHub Release check completed" switch status { case pagesSourceStatusUpdateAvailable: - message = "发现新的 GitHub Release 部署包" + message = "A new GitHub Release deployment archive is available" case pagesSourceStatusAttention: - message = "检测到同一 Release 的资源被替换,需要确认" + message = "An asset in the same release was replaced; confirmation is required" } return &githubCheckTaskResult{ Message: message, Detail: string(detail), Revision: target.Revision, @@ -454,7 +454,7 @@ func activatePreparedGitHubSource( triggerType string, prepared *preparedGitHubSource, ) (*sourceSyncOutcome, error) { - task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署") + task.AppendLog(ctx, "[activate] Atomically switching GitHub Release deployment") deployment, reused, referenced, err := commitSourceDeploymentWithTrigger( ctx, snapshot, prepared.target.Revision, prepared.download.SHA256, prepared.target.Detail, prepared.target.DetailJSON, actor, triggerType, prepared.manifest, @@ -482,7 +482,7 @@ func resolveAndGuardGitHubSync( targetRevision string, confirmedRevision string, ) (*githubSourceTarget, *sourceSyncOutcome, error) { - task.AppendLog(ctx, "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName) + task.AppendLog(ctx, "[resolve] Resolving GitHub Release: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName) resolved, err := client.Resolve(ctx, githubrelease.ResolveRequest{ Repository: snapshot.GitHubRepository, Selector: githubrelease.Selector(snapshot.ReleaseSelector), @@ -557,7 +557,7 @@ func prepareGitHubSyncPackage( target *githubSourceTarget, ) (*preparedGitHubSource, error) { limits := resolvePagesLimits(ctx) - task.AppendLog(ctx, "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName) + task.AppendLog(ctx, "[download] Downloading GitHub Release asset: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName) download, err := client.Download(ctx, githubrelease.DownloadRequest{ Repository: snapshot.GitHubRepository, Asset: target.Asset, @@ -606,7 +606,7 @@ func inspectAndIngestGitHubPackage( if err != nil { return nil, err } - task.AppendLog(ctx, "[verify] 正在校验 GitHub Release 归档与入口") + task.AppendLog(ctx, "[verify] Verifying GitHub Release archive and entry file") manifest, err := inspectPagesPackage(download.Path, format, rootDir, entryFile, limits) if err != nil { return nil, err @@ -633,7 +633,7 @@ func resolveGitHubSourceIngest( } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } - task.AppendLog(ctx, "[ingest] 正在保存 GitHub Release 部署包") + task.AppendLog(ctx, "[ingest] Saving GitHub Release deployment archive") result, err := ingestPagesDeploymentPackageWithSource( ctx, download.Path, download.SHA256, snapshot.ProjectID, snapshot.SourceID, target.Asset.Name, format, ) diff --git a/internal/apps/openflare/pages/helpers.go b/internal/apps/openflare/pages/helpers.go index 2cd66190..5608136e 100644 --- a/internal/apps/openflare/pages/helpers.go +++ b/internal/apps/openflare/pages/helpers.go @@ -116,11 +116,11 @@ func normalizePagesSlug(raw string) string { func validateAndNormalizePagesRootDir(raw string) (string, error) { value := strings.TrimSpace(raw) if len(value) > pagesMaxPathLength { - return "", errors.New("pages 根目录长度不能超过 512") + return "", errors.New("pages root directory must not exceed 512 characters") } normalized, err := pagesarchive.NormalizeLogicalPath(value, true) if err != nil { - return "", fmt.Errorf("pages 根目录不合法: %w", err) + return "", fmt.Errorf("invalid Pages root directory: %w", err) } return normalized, nil } @@ -131,33 +131,33 @@ func normalizePagesFallbackPath(raw string) (string, error) { value = defaultPagesFallbackPath } if len(value) > pagesMaxPathLength { - return "", errors.New("spa fallback 回退路径长度不能超过 512") + return "", errors.New("SPA fallback path must not exceed 512 characters") } if !strings.HasPrefix(value, "/") { - return "", errors.New("spa fallback 回退路径必须以 / 开头") + return "", errors.New("SPA fallback path must start with /") } if value == "/" || strings.HasSuffix(value, "/") { - return "", errors.New("spa fallback 回退路径必须指向具体文件") + return "", errors.New("SPA fallback path must point to a specific file") } if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") { - return "", errors.New("spa fallback 回退路径包含不支持的字符") + return "", errors.New("SPA fallback path contains unsupported characters") } for _, r := range value { if r <= 0x20 || r == 0x7f { - return "", errors.New("spa fallback 回退路径不能包含空白或控制字符") + return "", errors.New("SPA fallback path must not contain whitespace or control characters") } } for segment := range strings.SplitSeq(value, "/") { if segment == "." || segment == ".." { - return "", errors.New("spa fallback 回退路径不能包含 . 或 .. 路径段") + return "", errors.New("SPA fallback path must not contain . or .. segments") } } cleaned := path.Clean(value) if cleaned == "." || !strings.HasPrefix(cleaned, "/") { - return "", errors.New("spa fallback 回退路径不合法") + return "", errors.New("invalid SPA fallback path") } if cleaned == "/" || strings.HasSuffix(cleaned, "/") { - return "", errors.New("spa fallback 回退路径必须指向具体文件") + return "", errors.New("SPA fallback path must point to a specific file") } return cleaned, nil } @@ -176,11 +176,11 @@ func validateAndNormalizePagesEntryFile(raw string) (string, error) { value = defaultPagesEntryFile } if len(value) > pagesMaxPathLength { - return "", errors.New("pages 入口文件长度不能超过 512") + return "", errors.New("pages entry file must not exceed 512 characters") } normalized, err := pagesarchive.NormalizeLogicalPath(value, false) if err != nil { - return "", fmt.Errorf("pages 入口文件不合法: %w", err) + return "", fmt.Errorf("invalid Pages entry file: %w", err) } return normalized, nil } @@ -209,7 +209,7 @@ func persistPagesUploadTemp(fileHeader *multipart.FileHeader, maxPackageBytes in } if written > maxPackageBytes { _ = os.Remove(temp.Name()) - return "", "", 0, "", fmt.Errorf("pages 部署包不能超过 %d MiB", maxPackageBytes/bytesPerMiB) + return "", "", 0, "", fmt.Errorf("pages deployment archive must not exceed %d MiB", maxPackageBytes/bytesPerMiB) } return temp.Name(), hex.EncodeToString(hash.Sum(nil)), written, format, nil } @@ -337,7 +337,7 @@ func removePagesUploadIfUnreferenced(ctx context.Context, projectID uint, upload return err } if uploadRecord.Type != upload.ReservedPagesDeploymentType { - return fmt.Errorf("pages 部署包上传类型不匹配: %s", uploadRecord.Type) + return fmt.Errorf("pages deployment archive upload type mismatch: %s", uploadRecord.Type) } var references int64 @@ -406,7 +406,7 @@ func mapPagesArchiveError(err error) error { case strings.Contains(message, "missing entry file"): return err case strings.Contains(message, "file count exceeds"): - return fmt.Errorf("pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles) + return fmt.Errorf("pages deployment must not contain more than %d files", pagesMaxDeploymentFiles) case strings.Contains(message, "extracted size exceeds"): return errors.New(errPagesPackageExtractedTooLarge) case strings.Contains(message, "file too large"), strings.Contains(message, "size out of bounds"): diff --git a/internal/apps/openflare/pages/logics.go b/internal/apps/openflare/pages/logics.go index 57cdf7f7..9a48cabf 100644 --- a/internal/apps/openflare/pages/logics.go +++ b/internal/apps/openflare/pages/logics.go @@ -958,7 +958,7 @@ func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeploymen } uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID) if err != nil { - return "", fmt.Errorf("pages 部署包不存在: %w", err) + return "", fmt.Errorf("pages deployment archive was not found: %w", err) } hash := strings.TrimSpace(uploadRecord.Hash) if hash == "" { @@ -973,7 +973,7 @@ func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeploymen func openDeploymentPackageFromUpload(ctx context.Context, uploadID uint64, deploymentID uint) (DeploymentPackage, error) { opened, err := upload.OpenStoredUpload(ctx, uploadID) if err != nil { - return DeploymentPackage{}, fmt.Errorf("pages 部署包不存在: %w", err) + return DeploymentPackage{}, fmt.Errorf("pages deployment archive was not found: %w", err) } contentType := opened.ContentType if contentType == "" { @@ -1028,7 +1028,7 @@ func hydrateLegacyDeploymentUpload( RelativePaths: pagesLegacyRelativeCandidates(project, deployment), }) if err != nil { - return nil, fmt.Errorf("pages 部署包不存在: %w", err) + return nil, fmt.Errorf("pages deployment archive was not found: %w", err) } ingestResult, err := ingestPagesDeploymentPackage( diff --git a/internal/apps/openflare/pages/logics_test.go b/internal/apps/openflare/pages/logics_test.go index 6d282a5a..33826e1f 100644 --- a/internal/apps/openflare/pages/logics_test.go +++ b/internal/apps/openflare/pages/logics_test.go @@ -163,7 +163,7 @@ func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) { SPAFallbackPath: "/index.html; proxy_pass http://evil", }) require.Error(t, err) - assert.Contains(t, err.Error(), "回退路径") + assert.Contains(t, err.Error(), "fallback path") } func TestCreateProjectRejectsUnsafeContentPaths(t *testing.T) { @@ -412,7 +412,7 @@ func TestOpenDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) { _, err = OpenDeploymentPackage(ctx, deployment.ID) require.Error(t, err) - assert.Contains(t, err.Error(), "激活配置") + assert.Contains(t, err.Error(), "active configuration") require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{ Version: "v2026-001", diff --git a/internal/apps/openflare/pages/package_metadata.go b/internal/apps/openflare/pages/package_metadata.go index 76caef92..a01ce088 100644 --- a/internal/apps/openflare/pages/package_metadata.go +++ b/internal/apps/openflare/pages/package_metadata.go @@ -45,7 +45,7 @@ func GetProjectLatestPackageMetadata(ctx context.Context, projectID uint) (*Proj uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID) if err != nil { - return nil, fmt.Errorf("pages 部署包不存在: %w", err) + return nil, fmt.Errorf("pages deployment archive was not found: %w", err) } hash := strings.TrimSpace(uploadRecord.Hash) if hash == "" { diff --git a/internal/apps/openflare/pages/rebind.go b/internal/apps/openflare/pages/rebind.go index 333894d9..c1c00ccd 100644 --- a/internal/apps/openflare/pages/rebind.go +++ b/internal/apps/openflare/pages/rebind.go @@ -100,7 +100,7 @@ func rebindPagesRouteMaps(ctx context.Context, routes []map[string]json.RawMessa if siteName == "" { siteName = "pages" } - return false, fmt.Errorf("路由 %s %w", siteName, err) + return false, fmt.Errorf("route %s %w", siteName, err) } project, activeDeployment, err := loadActivePagesProject(ctx, projectID, siteName) if err != nil { @@ -152,7 +152,7 @@ func resolveProjectIDFromRouteMap(route map[string]json.RawMessage) (uint, error return deployment.ProjectID, nil } } - return 0, errors.New("pages 配置无效: 缺少 pages_project_id") + return 0, errors.New("invalid Pages configuration: missing pages_project_id") } func loadActivePagesProject(ctx context.Context, projectID uint, siteName string) (*model.PagesProject, *model.PagesDeployment, error) { @@ -162,28 +162,28 @@ func loadActivePagesProject(ctx context.Context, projectID uint, siteName string project, err := repository.GetPagesProjectByID(ctx, projectID) if err != nil { if errorsIsNotFound(err) { - return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", siteName) + return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project was not found", siteName) } return nil, nil, err } if !project.Enabled { - return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", siteName) + return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project is disabled", siteName) } if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 { - return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", siteName) + return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project has no active deployment", siteName) } activeDeployment, err := repository.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID) if err != nil { if errorsIsNotFound(err) { - return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", siteName) + return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment was not found", siteName) } return nil, nil, err } if activeDeployment.ProjectID != project.ID { - return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", siteName) + return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment does not match", siteName) } if strings.TrimSpace(activeDeployment.Checksum) == "" { - return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", siteName) + return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages deployment checksum is missing", siteName) } return project, activeDeployment, nil } diff --git a/internal/apps/openflare/pages/routers.go b/internal/apps/openflare/pages/routers.go index e07cd496..fb553a1c 100644 --- a/internal/apps/openflare/pages/routers.go +++ b/internal/apps/openflare/pages/routers.go @@ -86,12 +86,12 @@ func decodeStrictJSON(c *gin.Context, target any, allowEmpty bool) bool { func deploymentIDParam(c *gin.Context) (uint, bool) { raw := c.Param("deployment_id") if raw == "" { - response.AbortBadRequest(c, "无效的 ID") + response.AbortBadRequest(c, "Invalid ID") return 0, false } id64, err := strconv.ParseUint(raw, 10, 64) if err != nil || id64 == 0 { - response.AbortBadRequest(c, "无效的 ID") + response.AbortBadRequest(c, "Invalid ID") return 0, false } return uint(id64), true diff --git a/internal/apps/openflare/pages/source_provider.go b/internal/apps/openflare/pages/source_provider.go index 2026dcd8..7aaf1ced 100644 --- a/internal/apps/openflare/pages/source_provider.go +++ b/internal/apps/openflare/pages/source_provider.go @@ -46,13 +46,13 @@ func (providerError remoteProviderError) Error() string { } const ( - errRemoteProviderInvalidLimit remoteProviderError = "远程来源部署包大小限制无效" - errRemoteProviderRedirectLimit remoteProviderError = "远程来源重定向次数超过限制" + errRemoteProviderInvalidLimit remoteProviderError = "Invalid remote source archive size limit" + errRemoteProviderRedirectLimit remoteProviderError = "Remote source redirect limit exceeded" errRemoteProviderDownloadFailed remoteProviderError = errPagesPackageURLDownloadFailed errRemoteProviderTooLarge remoteProviderError = errPagesPackageURLTooLarge errRemoteProviderEmpty remoteProviderError = errPagesPackageEmpty errRemoteProviderUnsupported remoteProviderError = errPagesPackageUnsupported - errRemoteProviderCleanupFailed remoteProviderError = "清理远程来源临时文件失败" + errRemoteProviderCleanupFailed remoteProviderError = "Failed to clean up remote source temporary file" ) // RemoteSourceRequest describes one immutable Remote URL package fetch. diff --git a/internal/apps/openflare/pages/source_scanner.go b/internal/apps/openflare/pages/source_scanner.go index 06510e38..73dbf837 100644 --- a/internal/apps/openflare/pages/source_scanner.go +++ b/internal/apps/openflare/pages/source_scanner.go @@ -30,8 +30,8 @@ const ( var PagesSourceScanMeta = task.TaskMeta{ Type: TaskTypePagesSourceScan, AsynqTask: PagesSourceScanTask, - Name: "OpenFlare Pages 部署源扫描", - Description: "补偿孤儿部署包、恢复过期执行权并串行检查到期的 GitHub latest 部署源", + Name: "OpenFlare Pages deployment source scan", + Description: "Clean up orphaned archives, recover expired source leases, and check GitHub sources tracking the latest release for updates", SupportsTime: false, MaxRetry: 0, Queue: task.QueueDefault, @@ -125,7 +125,7 @@ func (handler *SourceScanHandler) Execute(ctx context.Context, payload []byte) ( summary.OrphanCleanup = orphanSummary task.AppendLog( ctx, - "[cleanup] orphan 候选=%d,已补偿=%d,仍被引用=%d,lease busy=%d,非法 marker=%d,跳过=%d,失败=%d", + "[cleanup] orphan candidates=%d, compensated=%d, referenced=%d, lease busy=%d, invalid markers=%d, skipped=%d, failed=%d", orphanSummary.Candidates, orphanSummary.Reconciled, orphanSummary.Referenced, @@ -143,7 +143,7 @@ func (handler *SourceScanHandler) Execute(ctx context.Context, payload []byte) ( return nil, err } message := fmt.Sprintf( - "Pages 部署源扫描完成:恢复 %d 个租约,补偿 %d 个孤儿记录,检查 %d 个来源,投递 %d 个自动更新,积压 %d 个", + "Pages source scan completed: recovered %d leases, compensated %d orphan records, checked %d sources, dispatched %d automatic updates, backlog %d", summary.RecoveredLeases, summary.OrphanCleanup.Reconciled, summary.CheckedSources, @@ -195,7 +195,7 @@ func recoverExpiredPagesSourceLeases( } if recovered { summary.RecoveredLeases++ - task.AppendLog(ctx, "[recover] 已恢复过期来源租约:source_id=%d", candidate.SourceID) + task.AppendLog(ctx, "[recover] Recovered expired source lease: source_id=%d", candidate.SourceID) } } return nil @@ -223,7 +223,7 @@ func scanDueGitHubSources( summary.SelectedSources = len(candidates) task.AppendLog( ctx, - "[scan] 到期来源=%d,本批=%d", + "[scan] Due sources=%d, batch=%d", summary.DueSources, summary.SelectedSources, ) @@ -238,7 +238,7 @@ func scanDueGitHubSources( return err } summary.Backlog = int(remainingDue) - task.AppendLog(ctx, "[scan] 本批处理后仍到期来源=%d", summary.Backlog) + task.AppendLog(ctx, "[scan] Sources still due after this batch=%d", summary.Backlog) return nil } @@ -261,7 +261,7 @@ func scanOneDueGitHubSource( switch outcome { case sourceLeaseBusy: summary.BusySources++ - task.AppendLog(ctx, "[check] 来源正在执行其它任务,跳过:source_id=%d", candidate.SourceID) + task.AppendLog(ctx, "[check] Source is running another task; skipping: source_id=%d", candidate.SourceID) return case sourceLeaseStale: summary.StaleSources++ @@ -333,7 +333,7 @@ func recordPagesSourceProviderBackoff( }) task.AppendLog( ctx, - "[check] GitHub provider 退避:source_id=%d status=%d retry_at=%s", + "[check] GitHub provider backoff: source_id=%d status=%d retry_at=%s", sourceID, domainError.statusCode, retryAtText, @@ -369,7 +369,7 @@ func handleCheckedGitHubSource( if receipt != nil { task.AppendLog( ctx, - "[dispatch] 已投递自动更新:source_id=%d execution_id=%s revision=%s", + "[dispatch] Automatic update dispatched: source_id=%d execution_id=%s revision=%s", snapshot.SourceID, receipt.ExecutionID, checkResult.Revision, diff --git a/internal/apps/openflare/pages/source_sync.go b/internal/apps/openflare/pages/source_sync.go index a7f93122..a936b5c0 100644 --- a/internal/apps/openflare/pages/source_sync.go +++ b/internal/apps/openflare/pages/source_sync.go @@ -224,7 +224,7 @@ func syncRemoteSourceWithTrigger( return &sourceSyncOutcome{Stale: true}, nil } - task.AppendLog(ctx, "[activate] 正在原子切换生产部署") + task.AppendLog(ctx, "[activate] Atomically switching production deployment") deployment, reused, referenced, err := commitSourceDeploymentWithTrigger( ctx, snapshot, @@ -266,7 +266,7 @@ func prepareRemoteSource( snapshot *sourceExecutionSnapshot, limits pagesLimits, ) (*preparedRemoteSource, error) { - task.AppendLog(ctx, "[download] 正在获取远程部署包") + task.AppendLog(ctx, "[download] Fetching remote deployment archive") candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{ URL: snapshot.RemoteURL, AllowInsecure: snapshot.AllowInsecure, @@ -288,7 +288,7 @@ func prepareRemoteSource( cleanupFailedRemoteCandidate(ctx, snapshot, candidate) return nil, err } - task.AppendLog(ctx, "[verify] 正在校验归档结构与入口文件") + task.AppendLog(ctx, "[verify] Verifying archive structure and entry file") manifest, err := inspectPagesPackage(candidate.TempPath, candidate.Format, rootDir, entryFile, limits) if err != nil { cleanupFailedRemoteCandidate(ctx, snapshot, candidate) @@ -335,7 +335,7 @@ func resolveSourceIngest( if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } - task.AppendLog(ctx, "[ingest] 正在保存受管部署包") + task.AppendLog(ctx, "[ingest] Saving managed deployment archive") result, err := ingestPagesDeploymentPackageWithSource( ctx, prepared.Candidate.TempPath, @@ -361,7 +361,7 @@ func compensateSourceIngest( } cleanupCtx, cancel := sourceCleanupContext(ctx) defer cancel() - task.AppendLog(cleanupCtx, "[cleanup] 正在补偿未引用的部署包记录") + task.AppendLog(cleanupCtx, "[cleanup] Compensating unreferenced deployment archive records") if err := removePagesUploadIfUnreferenced(cleanupCtx, snapshot.ProjectID, state.Result.Upload.ID); err != nil { logger.ErrorF(cleanupCtx, "[PagesSource] compensate upload failed: project_id=%d source_id=%d upload_id=%d error=%v", diff --git a/internal/apps/openflare/pages/source_tasks.go b/internal/apps/openflare/pages/source_tasks.go index 9d3b314a..ee3a5161 100644 --- a/internal/apps/openflare/pages/source_tasks.go +++ b/internal/apps/openflare/pages/source_tasks.go @@ -36,8 +36,8 @@ var errUnexpectedJSONTrailingValue = errors.New("unexpected trailing JSON value" var PagesSourceActionMeta = task.TaskMeta{ Type: TaskTypePagesSourceAction, AsynqTask: PagesSourceActionTask, - Name: "OpenFlare Pages 部署源操作", - Description: "检查或同步 Pages 项目部署源", + Name: "OpenFlare Pages deployment source operation", + Description: "Check or synchronize a Pages project deployment source", SupportsTime: false, MaxRetry: 2, Queue: task.QueueDefault, @@ -138,14 +138,14 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas source, err := repository.GetPagesProjectSourceByID(ctx, input.SourceID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - task.AppendLog(ctx, "[resolve] 来源已不存在,本次任务跳过") + task.AppendLog(ctx, "[resolve] Source no longer exists; skipping task") return &task.TaskResult{Message: errPagesSourceActionStale}, nil } logger.ErrorF(ctx, "[PagesSource] load source failed: source_id=%d error=%v", input.SourceID, err) return nil, errors.New(errPagesSourceSyncFailed) } if source.ConfigVersion != input.ConfigVersion { - task.AppendLog(ctx, "[resolve] 来源配置已变化,本次任务跳过") + task.AppendLog(ctx, "[resolve] Source configuration changed; skipping task") return &task.TaskResult{Message: errPagesSourceActionStale}, nil } if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL { @@ -159,7 +159,7 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas return nil, task.PermanentError(errPagesSourceActionInvalid) } - task.AppendLog(ctx, "[resolve] 正在获取来源执行权") + task.AppendLog(ctx, "[resolve] Acquiring source task ownership") snapshot, outcome, err := acquireSourceLease(ctx, input.SourceID, input.ConfigVersion, input.Action) if err != nil { logger.ErrorF(ctx, "[PagesSource] acquire lease failed: source_id=%d error=%v", input.SourceID, err) @@ -167,10 +167,10 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas } switch outcome { case sourceLeaseBusy: - task.AppendLog(ctx, "[resolve] 已有来源任务正在执行,本次任务跳过") + task.AppendLog(ctx, "[resolve] Another source task is running; skipping task") return &task.TaskResult{Message: errPagesSourceActionBusy}, nil case sourceLeaseStale: - task.AppendLog(ctx, "[resolve] 来源配置或执行权已变化,本次任务跳过") + task.AppendLog(ctx, "[resolve] Source configuration or ownership changed; skipping task") return &task.TaskResult{Message: errPagesSourceActionStale}, nil case sourceLeaseAcquired: // 已获取执行权,继续执行。 @@ -220,12 +220,12 @@ func executeSourceSyncAction( return nil, errors.New(errPagesSourceSyncFailed) } if result == nil || result.Stale { - task.AppendLog(ctx, "[activate] 来源配置或执行权已变化,本次任务未切换部署") + task.AppendLog(ctx, "[activate] Source configuration or ownership changed; deployment was not switched") return &task.TaskResult{Message: errPagesSourceActionStale}, nil } - message := "Pages 部署源同步并发布成功" + message := "Pages deployment source synchronized and published successfully" if result.Reused { - message = "Pages 部署源内容未变化,已重新激活现有部署" + message = "Pages source content is unchanged; existing deployment reactivated" } return &task.TaskResult{Message: message, Detail: sourceSyncResultDetail(result)}, nil } diff --git a/internal/apps/openflare/proxy_route/errs.go b/internal/apps/openflare/proxy_route/errs.go index f7c2fff8..9a451be7 100644 --- a/internal/apps/openflare/proxy_route/errs.go +++ b/internal/apps/openflare/proxy_route/errs.go @@ -43,12 +43,12 @@ const ( errProxyRouteTunnelAddrReq = "tunnel_target_addr is required for tunnel upstream" errProxyRouteTunnelProtocol = "tunnel_target_protocol must be http or https" errProxyRoutePagesProjectReq = "pages_project_id is required for Pages upstream" - errProxyRoutePagesNotFound = "pages 项目不存在" - errProxyRoutePagesDisabled = "pages 项目未启用" - errProxyRoutePagesNoDeploy = "pages 项目没有激活部署" - errProxyRouteOriginSchemeOnly = "源站协议仅支持 http 或 https" - errProxyRouteOriginPort = "端口格式不合法" - errProxyRouteOriginPortEmpty = "端口不能为空" - errProxyRouteOriginURI = "源站路径需以 / 或 ? 开头" - errProxyRouteOriginURIProto = "源站路径不能包含协议" + errProxyRoutePagesNotFound = "pages project was not found" + errProxyRoutePagesDisabled = "pages project is disabled" + errProxyRoutePagesNoDeploy = "pages project has no active deployment" + errProxyRouteOriginSchemeOnly = "origin protocol must be http or https" + errProxyRouteOriginPort = "invalid port format" + errProxyRouteOriginPortEmpty = "port must not be empty" + errProxyRouteOriginURI = "origin path must start with / or ?" + errProxyRouteOriginURIProto = "origin path must not contain a protocol" ) diff --git a/internal/apps/openflare/proxy_route/helpers.go b/internal/apps/openflare/proxy_route/helpers.go index 98418483..d7aeef81 100644 --- a/internal/apps/openflare/proxy_route/helpers.go +++ b/internal/apps/openflare/proxy_route/helpers.go @@ -361,7 +361,7 @@ func normalizeProxyRouteLimitReqPerIP(raw string) (string, error) { return "-1", nil } if !proxyRouteLimitReqPattern.MatchString(normalized) { - return "", errors.New("请求频率格式不合法,请使用类似 10r/s、100r/m,或 -1 禁用") + return "", errors.New("invalid request rate; use a rate such as 10r/s or 100r/m, or -1 to disable") } return normalized, nil } diff --git a/internal/apps/openflare/relay/errs.go b/internal/apps/openflare/relay/errs.go index 230240ec..a26b4647 100644 --- a/internal/apps/openflare/relay/errs.go +++ b/internal/apps/openflare/relay/errs.go @@ -6,6 +6,6 @@ package relay const ( //nolint:gosec // error message text, not a credential - errAgentTokenInvalid = "无权进行此操作,Agent Token 无效" - errRelayNodeTypeMismatch = "此节点不是 TunnelRelay 类型" + errAgentTokenInvalid = "Operation not authorized: invalid agent token" + errRelayNodeTypeMismatch = "This node is not a TunnelRelay" ) diff --git a/internal/apps/openflare/tasks/log_db_switch.go b/internal/apps/openflare/tasks/log_db_switch.go index 78d193d0..a0a3df51 100644 --- a/internal/apps/openflare/tasks/log_db_switch.go +++ b/internal/apps/openflare/tasks/log_db_switch.go @@ -40,11 +40,11 @@ type LogDBSwitchHandler struct{} func (h *LogDBSwitchHandler) ValidatePayload(payload []byte) ([]byte, error) { var p logDBSwitchPayload if err := json.Unmarshal(payload, &p); err != nil { - return nil, fmt.Errorf("参数解析失败: %w", err) + return nil, fmt.Errorf("failed to parse parameters: %w", err) } p.Target = normalizeTarget(p.Target) if !validTarget(p.Target) { - return nil, fmt.Errorf("目标日志库不合法: %s", p.Target) + return nil, fmt.Errorf("invalid target log database: %s", p.Target) } out, err := json.Marshal(p) if err != nil { @@ -73,7 +73,7 @@ func validTarget(v string) bool { func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { var p logDBSwitchPayload if err := json.Unmarshal(payload, &p); err != nil { - return nil, fmt.Errorf("参数解析失败: %w", err) + return nil, fmt.Errorf("failed to parse parameters: %w", err) } p.Target = normalizeTarget(p.Target) if err := validateSwitch(ctx, p.Target); err != nil { @@ -82,10 +82,10 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task source, err := currentLogDatabase(ctx) if err != nil { - task.AppendLog(ctx, "读取日志主库失败: %v", err) + task.AppendLog(ctx, "Failed to read log database: %v", err) return nil, err } - task.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target) + task.AppendLog(ctx, "Starting log database migration: %s -> %s", source, p.Target) // 设置迁移冻结标记(置位后由 ensureWritable 拒绝新写入)。 if err := setMigrationFlag(ctx, "migrating"); err != nil { @@ -94,14 +94,14 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task // 失败也清除(SaveOrUpdateSystemConfig 会失效 RAM 缓存并广播),保持源库可写。 defer func() { if err := setMigrationFlag(ctx, ""); err != nil { - logger.ErrorF(ctx, "清除日志迁移冻结标记失败: %v", err) + logger.ErrorF(ctx, "Failed to clear log migration freeze marker: %v", err) } }() // 冻结标记置位后再排空在途批次(chwriter + 用户访问日志 writer), // 保证排空完成后不再有新批次进入源库。 if err := drainLogWriters(ctx); err != nil { - return nil, fmt.Errorf("排空日志写入队列失败: %w", err) + return nil, fmt.Errorf("failed to drain log write queue: %w", err) } src, err := logstore.Active(ctx) @@ -138,8 +138,8 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task if err := flipLogDatabase(ctx, p.Target); err != nil { return nil, err } - task.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target) - return &task.TaskResult{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil + task.AppendLog(ctx, "Log database switched to %s; writes resumed", p.Target) + return &task.TaskResult{Message: fmt.Sprintf("Log database switched from %s to %s", source, p.Target)}, nil } func validateSwitch(ctx context.Context, target string) error { @@ -148,20 +148,20 @@ func validateSwitch(ctx context.Context, target string) error { return err } if source == target { - return errors.New("目标日志库与当前日志库相同,无需迁移") + return errors.New("target log database is already active; no migration is required") } switch target { case "clickhouse": if !config.Config.ClickHouse.Enabled { - return errors.New("ClickHouse 未启用,无法迁移到 ClickHouse") + return errors.New("ClickHouse is disabled; migration to ClickHouse is unavailable") } case "postgres": if !config.Config.Database.Enabled { - return errors.New("PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL") + return errors.New("PostgreSQL is disabled and SQLite is the primary database; migration to PostgreSQL is unavailable") } case "sqlite": if config.Config.Database.Enabled { - return errors.New("当前主库为 PostgreSQL,日志库不能设置为 SQLite") + return errors.New("PostgreSQL is the primary database; SQLite cannot be selected for log storage") } } return nil @@ -170,10 +170,10 @@ func validateSwitch(ctx context.Context, target string) error { func currentLogDatabase(ctx context.Context) (string, error) { cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase) if err != nil { - return "", fmt.Errorf("读取日志主库失败: %w", err) + return "", fmt.Errorf("failed to read log database: %w", err) } if cfg.Value == "" { - return "", errors.New("日志主库配置为空") + return "", errors.New("log database configuration is empty") } return cfg.Value, nil } @@ -205,10 +205,10 @@ func clearTargetLogTables(ctx context.Context, dst *logstore.Store) error { // 依次清空 6 张表:AccessLogs.DeleteAll、UserAccessLogs.DeleteAll、Observability.DeleteAll* // (SQLite/PG 用 DeleteAll;CH 用 TRUNCATE 语义)。 if _, err := dst.AccessLogs.DeleteAll(ctx); err != nil { - return fmt.Errorf("清空目标访问日志失败: %w", err) + return fmt.Errorf("failed to clear target access logs: %w", err) } if _, err := dst.UserAccessLogs.DeleteAll(ctx); err != nil { - return fmt.Errorf("清空目标用户访问日志失败: %w", err) + return fmt.Errorf("failed to clear target user access logs: %w", err) } for _, fn := range []func(context.Context) (int64, error){ dst.Observability.DeleteAllMetricSnapshots, @@ -234,13 +234,13 @@ func ensureTargetPartitions(ctx context.Context, src, dst *logstore.Store, targe return err } if from.IsZero() || to.IsZero() { - task.AppendLog(ctx, "源库无日志数据,跳过分区预建") + task.AppendLog(ctx, "Source database contains no logs; skipping partition precreation") return nil } if err := dst.AccessLogs.EnsurePartitions(ctx, from, to.AddDate(0, 1, 0)); err != nil { - return fmt.Errorf("预建目标 PG 分区失败: %w", err) + return fmt.Errorf("failed to precreate target PostgreSQL partitions: %w", err) } - task.AppendLog(ctx, "已为目标 PG 预建分区 %s ~ %s", from.Format("2006-01"), to.Format("2006-01")) + task.AppendLog(ctx, "Precreated target PostgreSQL partitions from %s to %s", from.Format("2006-01"), to.Format("2006-01")) return nil } @@ -249,11 +249,11 @@ func ensureTargetPartitions(ctx context.Context, src, dst *logstore.Store, targe func migrationRange(ctx context.Context, src *logstore.Store) (time.Time, time.Time, error) { fromAccess, toAccess, err := src.AccessLogs.MigrationRange(ctx) if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("读取源访问日志时间范围失败: %w", err) + return time.Time{}, time.Time{}, fmt.Errorf("failed to read source access log time range: %w", err) } fromUser, toUser, err := src.UserAccessLogs.MigrationRange(ctx) if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("读取源用户访问日志时间范围失败: %w", err) + return time.Time{}, time.Time{}, fmt.Errorf("failed to read source user access log time range: %w", err) } return minTime(fromAccess, fromUser), maxTime(toAccess, toUser), nil } @@ -297,9 +297,9 @@ func copyAccessLogs(ctx context.Context, src, dst *logstore.Store) error { break } if err := dst.AccessLogs.BatchInsertNodeAccessLogs(ctx, rows); err != nil { - return fmt.Errorf("写入目标访问日志失败(批 %d): %w", lastID, err) + return fmt.Errorf("failed to write target access logs (batch %d): %w", lastID, err) } - task.AppendLog(ctx, "已复制访问日志 %d 条(截至 id=%d)", len(rows), rows[len(rows)-1].ID) + task.AppendLog(ctx, "Copied %d access logs (through id=%d)", len(rows), rows[len(rows)-1].ID) lastID = rows[len(rows)-1].ID if len(rows) < copyBatchSize { break @@ -324,10 +324,10 @@ func copyUserAccessLogs(ctx context.Context, src, dst *logstore.Store) error { return nil } if err := dst.UserAccessLogs.BatchInsert(ctx, rows); err != nil { - return fmt.Errorf("写入目标用户访问日志失败(批 %d): %w", lastID, err) + return fmt.Errorf("failed to write target user access logs (batch %d): %w", lastID, err) } lastID = rows[len(rows)-1].ID - task.AppendLog(ctx, "已复制用户访问日志 %d 条(截至 id=%d)", len(rows), lastID) + task.AppendLog(ctx, "Copied %d user access logs (through id=%d)", len(rows), lastID) if len(rows) < copyBatchSize { return nil } @@ -374,16 +374,16 @@ func copyObsTable[T any](ctx context.Context, name string, for { rows, err := list(ctx, lastID, copyBatchSize) if err != nil { - return fmt.Errorf("复制 %s 失败: %w", name, err) + return fmt.Errorf("failed to copy %s: %w", name, err) } if len(rows) == 0 { return nil } if err := insert(ctx, rows); err != nil { - return fmt.Errorf("复制 %s 失败: %w", name, err) + return fmt.Errorf("failed to copy %s: %w", name, err) } lastID = idOf(rows) - task.AppendLog(ctx, "已复制 %s %d 条(截至 id=%d)", name, len(rows), lastID) + task.AppendLog(ctx, "Copied %s: %d records (through id=%d)", name, len(rows), lastID) if len(rows) < copyBatchSize { return nil } diff --git a/internal/apps/openflare/tasks/log_db_switch_test.go b/internal/apps/openflare/tasks/log_db_switch_test.go index 727895fb..9daeb917 100644 --- a/internal/apps/openflare/tasks/log_db_switch_test.go +++ b/internal/apps/openflare/tasks/log_db_switch_test.go @@ -231,21 +231,21 @@ func TestValidateSwitch(t *testing.T) { config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false err := validateSwitch(ctx, "sqlite") require.Error(t, err) - assert.Contains(t, err.Error(), "相同") + assert.Contains(t, err.Error(), "already active") }) t.Run("clickhouse disabled rejected", func(t *testing.T) { setLogDB("sqlite") config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false err := validateSwitch(ctx, "clickhouse") require.Error(t, err) - assert.Contains(t, err.Error(), "ClickHouse 未启用") + assert.Contains(t, err.Error(), "ClickHouse is disabled") }) t.Run("postgres requires main db enabled", func(t *testing.T) { setLogDB("sqlite") config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false err := validateSwitch(ctx, "postgres") require.Error(t, err) - assert.Contains(t, err.Error(), "PostgreSQL 未启用") + assert.Contains(t, err.Error(), "PostgreSQL is disabled") }) t.Run("sqlite rejected when main db is postgres", func(t *testing.T) { setLogDB("postgres") diff --git a/internal/apps/openflare/tls/errs.go b/internal/apps/openflare/tls/errs.go index 26140e21..b495f402 100644 --- a/internal/apps/openflare/tls/errs.go +++ b/internal/apps/openflare/tls/errs.go @@ -15,7 +15,7 @@ const ( errCertificateAlreadyApplying = "certificate is already applying" errCertificateOnlyACMERenew = "only acme certificates can be renewed" errCertificateFilesRequired = "certificate file and key file cannot be empty" - errCertificatePEMInvalid = "证书 PEM 内容不合法" + errCertificatePEMInvalid = "invalid certificate PEM content" - errDNSAccountInUse = "该 DNS 账号已被证书使用,无法删除" + errDNSAccountInUse = "this DNS account is used by certificates and cannot be deleted" ) diff --git a/internal/apps/openflare/tls/logics.go b/internal/apps/openflare/tls/logics.go index 60b427c0..96caa6c2 100644 --- a/internal/apps/openflare/tls/logics.go +++ b/internal/apps/openflare/tls/logics.go @@ -329,7 +329,7 @@ func CreateDNSAccount(ctx context.Context, input DNSAccountInput) (*model.DNSAcc Authorization: authorization, } if account.Name == "" || account.Type == "" || authorization == "" { - return nil, errors.New("DNS 账号参数不完整") + return nil, errors.New("DNS account parameters are incomplete") } if err := repository.CreateDNSAccountRecord(ctx, account); err != nil { return nil, err @@ -351,7 +351,7 @@ func UpdateDNSAccount(ctx context.Context, id uint, input DNSAccountInput) (*mod account.Type = strings.TrimSpace(input.Type) account.Authorization = authorization if account.Name == "" || account.Type == "" || authorization == "" { - return nil, errors.New("DNS 账号参数不完整") + return nil, errors.New("DNS account parameters are incomplete") } if err := repository.SaveDNSAccount(ctx, account); err != nil { return nil, err diff --git a/internal/apps/openflare/tls/obtain.go b/internal/apps/openflare/tls/obtain.go index 89c0a0f7..e2a8d828 100644 --- a/internal/apps/openflare/tls/obtain.go +++ b/internal/apps/openflare/tls/obtain.go @@ -35,25 +35,25 @@ func SetObtainCertificateFuncForTest(fn func(context.Context, *model.TLSCertific } func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error { - task.AppendLog(ctx, "【续签任务】开始续签,设置申请状态为 applying...") + task.AppendLog(ctx, "[Renewal] Starting renewal; setting application status to applying...") cert.ApplyStatus = tlsApplyStatusApplying if err := repository.SaveTLSCertificate(ctx, cert); err != nil { return err } - task.AppendLog(ctx, "【续签任务】正在解析 ACME 账户...") + task.AppendLog(ctx, "[Renewal] Resolving ACME account...") acmeAccount, err := resolveAcmeAccount(ctx, cert) if err != nil { return updateCertError(ctx, cert, fmt.Sprintf("Failed to get ACME account: %v", err)) } - task.AppendLog(ctx, "【续签任务】正在解析 DNS 账户信息 (ID=%d)...", cert.DNSAccountID) + task.AppendLog(ctx, "[Renewal] Resolving DNS account information (ID=%d)...", cert.DNSAccountID) dnsAccount, err := repository.GetDNSAccountByID(ctx, cert.DNSAccountID) if err != nil { return updateCertError(ctx, cert, fmt.Sprintf("Failed to get DNS account: %v", err)) } - task.AppendLog(ctx, "【续签任务】正在解密 DNS 账号凭据及 ACME 账户私钥...") + task.AppendLog(ctx, "[Renewal] Decrypting DNS account credentials and ACME account private key...") dnsAuth, err := openSensitive(dnsAccount.Authorization) if err != nil { return updateCertError(ctx, cert, fmt.Sprintf("Failed to decrypt DNS credentials: %v", err)) @@ -65,9 +65,9 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error { } domains := splitAcmeDomains(cert.PrimaryDomain, cert.OtherDomains) - task.AppendLog(ctx, "【续签任务】待申请的域名列表: %v", domains) + task.AppendLog(ctx, "[Renewal] Requested domains: %v", domains) - task.AppendLog(ctx, "【续签任务】正在调用 ACME 客户端(通过 DNS-01 挑战)发起 SSL 证书签发请求,请稍候...") + task.AppendLog(ctx, "[Renewal] Requesting SSL certificate issuance through the ACME DNS-01 challenge; please wait...") newAccountURL, newPrivateKeyPEM, result, err := acme.ObtainSSL( acmeAccount.Email, acmePrivateKey, @@ -82,7 +82,7 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error { domains, ) - task.AppendLog(ctx, "【续签任务】正在保存 ACME 账户可能的变更...") + task.AppendLog(ctx, "[Renewal] Saving ACME account changes...") if err := persistAcmeAccountUpdates(ctx, cert, acmeAccount, newAccountURL, newPrivateKeyPEM, acmePrivateKey); err != nil { return updateCertError(ctx, cert, err.Error()) } @@ -91,11 +91,11 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error { return updateCertError(ctx, cert, err.Error()) } - task.AppendLog(ctx, "【续签任务】证书签发成功,正在将证书内容与私钥安全写入数据库...") + task.AppendLog(ctx, "[Renewal] Certificate issued; securely storing certificate and private key in the database...") if err := saveObtainedCertificate(ctx, cert, result); err != nil { return updateCertError(ctx, cert, err.Error()) } - task.AppendLog(ctx, "【续签任务】证书数据存储完成!") + task.AppendLog(ctx, "[Renewal] Certificate data saved") return nil } diff --git a/internal/apps/openflare/tls/routers.go b/internal/apps/openflare/tls/routers.go index 4838965a..ce328f0b 100644 --- a/internal/apps/openflare/tls/routers.go +++ b/internal/apps/openflare/tls/routers.go @@ -15,7 +15,7 @@ func handleLogicError(c *gin.Context, err error) bool { if err == nil { return false } - return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在") + return apiutil.AbortNotFoundIfMissing(c, err, "Record was not found") } // GetCertificates 列出 TLS 证书。 @@ -170,12 +170,12 @@ func ImportCertificateFile(c *gin.Context) { remark := c.PostForm("remark") certFile, err := c.FormFile("cert_file") if err != nil { - response.AbortBadRequest(c, "缺少证书文件") + response.AbortBadRequest(c, "Missing certificate file") return } keyFile, err := c.FormFile("key_file") if err != nil { - response.AbortBadRequest(c, "缺少私钥文件") + response.AbortBadRequest(c, "Missing private key file") return } certificate, err := CreateCertificateFromFiles(c.Request.Context(), name, certFile, keyFile, remark) diff --git a/internal/apps/openflare/tls/tasks.go b/internal/apps/openflare/tls/tasks.go index 3dceff00..bda68240 100644 --- a/internal/apps/openflare/tls/tasks.go +++ b/internal/apps/openflare/tls/tasks.go @@ -25,8 +25,8 @@ const ( var SSLSingleRenewMeta = task.TaskMeta{ Type: TaskTypeSSLSingleRenew, AsynqTask: SSLSingleRenewTask, - Name: "OpenFlare 单证书 SSL 续期", - Description: "对单个指定的 ACME 证书执行续期", + Name: "OpenFlare single-certificate SSL renewal", + Description: "Renew a single specified ACME certificate", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -34,11 +34,11 @@ var SSLSingleRenewMeta = task.TaskMeta{ Params: []task.TaskParam{ { Name: "id", - Label: "证书 ID", + Label: "Certificate ID", Type: "number", Required: true, - Placeholder: "请输入证书 ID", - Description: "待续期的 TLS 证书 ID", + Placeholder: "Enter the certificate ID", + Description: "ID of the TLS certificate to renew", }, }, } @@ -54,16 +54,16 @@ type SSLSingleRenewHandler struct{} // ValidatePayload validates and normalizes the task payload. func (h *SSLSingleRenewHandler) ValidatePayload(payload []byte) ([]byte, error) { if len(payload) == 0 { - return nil, errors.New("任务参数不能为空") + return nil, errors.New("task parameters must not be empty") } var req SSLSingleRenewPayload if err := json.Unmarshal(payload, &req); err != nil { - return nil, fmt.Errorf("无效的 JSON 格式: %w", err) + return nil, fmt.Errorf("invalid JSON format: %w", err) } if req.ID == 0 { - return nil, errors.New("证书 ID 不能为空或零") + return nil, errors.New("certificate ID must be nonzero") } return json.Marshal(req) @@ -73,29 +73,29 @@ func (h *SSLSingleRenewHandler) ValidatePayload(payload []byte) ([]byte, error) func (h *SSLSingleRenewHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { var req SSLSingleRenewPayload if err := json.Unmarshal(payload, &req); err != nil { - return nil, fmt.Errorf("解析任务参数: %w", err) + return nil, fmt.Errorf("parse task parameters: %w", err) } - task.AppendLog(ctx, "开始续期证书,ID: %d", req.ID) + task.AppendLog(ctx, "Starting certificate renewal, ID: %d", req.ID) cert, err := repository.GetTLSCertificateByID(ctx, req.ID) if err != nil { - task.AppendLog(ctx, "获取证书记录失败 ID=%d: %v", req.ID, err) - return nil, fmt.Errorf("获取证书记录失败: %w", err) + task.AppendLog(ctx, "Failed to get certificate record ID=%d: %v", req.ID, err) + return nil, fmt.Errorf("failed to get certificate record: %w", err) } if cert.Provider != tlsProviderACME { - task.AppendLog(ctx, "证书 %s (ID=%d) 不是 ACME 托管证书,无法自动续期 (Provider: %s)", cert.PrimaryDomain, req.ID, cert.Provider) - return nil, fmt.Errorf("证书 %s 不是 ACME 托管证书,无法自动续期", cert.PrimaryDomain) + task.AppendLog(ctx, "Certificate %s (ID=%d) is not ACME-managed and cannot be renewed automatically (provider: %s)", cert.PrimaryDomain, req.ID, cert.Provider) + return nil, fmt.Errorf("certificate %s is not ACME-managed and cannot be renewed automatically", cert.PrimaryDomain) } - task.AppendLog(ctx, "准备为域名 [%s] 申请/续期证书", cert.PrimaryDomain) + task.AppendLog(ctx, "Preparing to issue or renew a certificate for domains [%s]", cert.PrimaryDomain) if err := obtainTLSCertificate(ctx, cert); err != nil { - task.AppendLog(ctx, "申请证书失败: %v", err) + task.AppendLog(ctx, "Certificate issuance failed: %v", err) return nil, err } - msg := fmt.Sprintf("证书 %s 续签成功", cert.PrimaryDomain) + msg := fmt.Sprintf("Certificate %s renewed successfully", cert.PrimaryDomain) task.AppendLog(ctx, "%s", msg) return &task.TaskResult{Message: msg}, nil } diff --git a/internal/apps/openflare/tls/tasks_test.go b/internal/apps/openflare/tls/tasks_test.go index 27267008..9269c3fa 100644 --- a/internal/apps/openflare/tls/tasks_test.go +++ b/internal/apps/openflare/tls/tasks_test.go @@ -27,19 +27,19 @@ func TestSSLSingleRenewHandler_ValidatePayload(t *testing.T) { name: "empty payload", payload: nil, wantErr: true, - errMsg: "任务参数不能为空", + errMsg: "task parameters must not be empty", }, { name: "invalid JSON", payload: []byte(`{`), wantErr: true, - errMsg: "无效的 JSON 格式", + errMsg: "invalid JSON format", }, { name: "zero ID", payload: []byte(`{"id":0}`), wantErr: true, - errMsg: "证书 ID 不能为空或零", + errMsg: "certificate ID must be nonzero", }, { name: "valid ID", @@ -84,7 +84,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) { require.NoError(t, err) _, err = handler.Execute(ctx, payload) require.Error(t, err) - assert.Contains(t, err.Error(), "获取证书记录失败") + assert.Contains(t, err.Error(), "failed to get certificate record") }) t.Run("certificate provider is not ACME", func(t *testing.T) { @@ -101,7 +101,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) { _, err = handler.Execute(ctx, payload) require.Error(t, err) - assert.Contains(t, err.Error(), "不是 ACME 托管证书") + assert.Contains(t, err.Error(), "is not ACME-managed") }) t.Run("successful renewal", func(t *testing.T) { @@ -125,7 +125,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) { res, err := handler.Execute(ctx, payload) require.NoError(t, err) - assert.Contains(t, res.Message, "续签成功") + assert.Contains(t, res.Message, "renewed successfully") updated, err := repository.GetTLSCertificateByID(ctx, cert.ID) require.NoError(t, err) diff --git a/internal/apps/openflare/waf/graph_compile.go b/internal/apps/openflare/waf/graph_compile.go index 7a36b761..e57451ad 100644 --- a/internal/apps/openflare/waf/graph_compile.go +++ b/internal/apps/openflare/waf/graph_compile.go @@ -32,32 +32,32 @@ func CompileRuleGraph(graph RuleGraph) (RuntimeRuleGraph, error) { for _, node := range graph.Nodes { config, err := compileRuleNodeConfig(node) if err != nil { - return RuntimeRuleGraph{}, fmt.Errorf("节点 %s 的配置无法编译: %w", node.ID, err) + return RuntimeRuleGraph{}, fmt.Errorf("unable to compile configuration for node %s: %w", node.ID, err) } if node.Type == RuleNodeStart { if runtime.Entry != "" { - return RuntimeRuleGraph{}, errors.New("规则图包含多个开始节点") + return RuntimeRuleGraph{}, errors.New("rule graph contains multiple start nodes") } runtime.Entry = node.ID } runtime.Nodes[node.ID] = RuntimeRuleNode{Type: node.Type, Config: config} } if runtime.Entry == "" { - return RuntimeRuleGraph{}, errors.New("规则图缺少开始节点") + return RuntimeRuleGraph{}, errors.New("rule graph is missing a start node") } for _, edge := range graph.Edges { node, ok := runtime.Nodes[edge.Source] if !ok { - return RuntimeRuleGraph{}, fmt.Errorf("边 %s 的源节点 %s 不存在", edge.ID, edge.Source) + return RuntimeRuleGraph{}, fmt.Errorf("edge %s references missing source node %s", edge.ID, edge.Source) } if _, ok := runtime.Nodes[edge.Target]; !ok { - return RuntimeRuleGraph{}, fmt.Errorf("边 %s 的目标节点 %s 不存在", edge.ID, edge.Target) + return RuntimeRuleGraph{}, fmt.Errorf("edge %s references missing target node %s", edge.ID, edge.Target) } if node.Next == nil { node.Next = make(map[string]string) } if _, exists := node.Next[edge.SourceHandle]; exists { - return RuntimeRuleGraph{}, fmt.Errorf("节点 %s 的 %s 出口连接了多个目标", edge.Source, edge.SourceHandle) + return RuntimeRuleGraph{}, fmt.Errorf("node %s output %s is connected to multiple targets", edge.Source, edge.SourceHandle) } node.Next[edge.SourceHandle] = edge.Target runtime.Nodes[edge.Source] = node @@ -109,7 +109,7 @@ func compileRuleNodeConfig(node RuleNode) (any, error) { var config BlockNodeConfig return config, decodeStrictConfig(node.Config, &config) default: - return nil, fmt.Errorf("未知节点类型 %s", node.Type) + return nil, fmt.Errorf("unknown node type %s", node.Type) } } diff --git a/internal/apps/openflare/waf/graph_validate.go b/internal/apps/openflare/waf/graph_validate.go index 2c9dc310..7d0d6eb0 100644 --- a/internal/apps/openflare/waf/graph_validate.go +++ b/internal/apps/openflare/waf/graph_validate.go @@ -44,7 +44,7 @@ func ValidateRuleGraph(ctx context.Context, graph RuleGraph, ipGroupExists func( return err } if hasRuleGraphCycle(nodes, outgoing, incoming) { - return errors.New("规则图不能包含循环") + return errors.New("rule graph must not contain cycles") } if err := validateRequiredHandles(graph.Nodes, handleTargets); err != nil { return err @@ -57,18 +57,18 @@ func ValidateRuleGraph(ctx context.Context, graph RuleGraph, ipGroupExists func( func validateRuleGraphLimits(graph RuleGraph) error { if graph.SchemaVersion != RuleGraphSchemaVersion { - return fmt.Errorf("规则图 schema_version 必须为 %d", RuleGraphSchemaVersion) + return fmt.Errorf("rule graph schema_version must be %d", RuleGraphSchemaVersion) } if len(graph.Nodes) > maxRuleGraphNodes { - return fmt.Errorf("规则图节点数不能超过 %d", maxRuleGraphNodes) + return fmt.Errorf("rule graph must not contain more than %d nodes", maxRuleGraphNodes) } if len(graph.Edges) > maxRuleGraphEdges { - return fmt.Errorf("规则图边数不能超过 %d", maxRuleGraphEdges) + return fmt.Errorf("rule graph must not contain more than %d edges", maxRuleGraphEdges) } if raw, err := json.Marshal(graph); err != nil { - return fmt.Errorf("规则图无法序列化: %w", err) + return fmt.Errorf("unable to serialize rule graph: %w", err) } else if len(raw) > maxRuleGraphBytes { - return errors.New("规则图大小不能超过 256 KiB") + return errors.New("rule graph must not exceed 256 KiB") } return nil } @@ -78,10 +78,10 @@ func validateRuleGraphNodes(ctx context.Context, graphNodes []RuleNode, ipGroupE startCount, allowCount, startID := 0, 0, "" for _, node := range graphNodes { if strings.TrimSpace(node.ID) == "" { - return nil, "", errors.New("节点 ID 不能为空") + return nil, "", errors.New("node ID must not be empty") } if _, exists := nodes[node.ID]; exists { - return nil, "", fmt.Errorf("节点 ID %s 重复", node.ID) + return nil, "", fmt.Errorf("duplicate node ID %s", node.ID) } nodes[node.ID] = node switch node.Type { @@ -92,17 +92,17 @@ func validateRuleGraphNodes(ctx context.Context, graphNodes []RuleNode, ipGroupE allowCount++ case RuleNodeBlock, RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodePoW, RuleNodeUACheck, RuleNodeSecurityCheck: default: - return nil, "", fmt.Errorf("节点 %s 的类型 %s 未知", node.ID, node.Type) + return nil, "", fmt.Errorf("node %s has unknown type %s", node.ID, node.Type) } if err := validateRuleNodeConfig(ctx, node, ipGroupExists); err != nil { return nil, "", err } } if startCount != 1 { - return nil, "", errors.New("规则图必须恰好包含一个开始节点") + return nil, "", errors.New("rule graph must contain exactly one start node") } if allowCount != 1 { - return nil, "", errors.New("规则图必须恰好包含一个通过节点") + return nil, "", errors.New("rule graph must contain exactly one allow node") } return nodes, startID, nil } @@ -114,26 +114,26 @@ func validateRuleGraphEdges(nodes map[string]RuleNode, graphEdges []RuleEdge) (m handleTargets := make(map[string]int) for _, edge := range graphEdges { if strings.TrimSpace(edge.ID) == "" { - return nil, nil, nil, errors.New("边 ID 不能为空") + return nil, nil, nil, errors.New("edge ID must not be empty") } if _, exists := edgeIDs[edge.ID]; exists { - return nil, nil, nil, fmt.Errorf("边 ID %s 重复", edge.ID) + return nil, nil, nil, fmt.Errorf("duplicate edge ID %s", edge.ID) } edgeIDs[edge.ID] = struct{}{} source, ok := nodes[edge.Source] if !ok { - return nil, nil, nil, fmt.Errorf("边 %s 的源节点 %s 不存在", edge.ID, edge.Source) + return nil, nil, nil, fmt.Errorf("edge %s references missing source node %s", edge.ID, edge.Source) } if _, ok := nodes[edge.Target]; !ok { - return nil, nil, nil, fmt.Errorf("边 %s 的目标节点 %s 不存在", edge.ID, edge.Target) + return nil, nil, nil, fmt.Errorf("edge %s references missing target node %s", edge.ID, edge.Target) } if !validSourceHandle(source.Type, edge.SourceHandle) { - return nil, nil, nil, fmt.Errorf("边 %s 的源端口 %s 不适用于节点 %s", edge.ID, edge.SourceHandle, edge.Source) + return nil, nil, nil, fmt.Errorf("edge %s source port %s is not valid for node %s", edge.ID, edge.SourceHandle, edge.Source) } key := edge.Source + "\x00" + edge.SourceHandle handleTargets[key]++ if handleTargets[key] > 1 { - return nil, nil, nil, fmt.Errorf("节点 %s 的 %s 出口连接了多个目标", edge.Source, edge.SourceHandle) + return nil, nil, nil, fmt.Errorf("node %s output %s is connected to multiple targets", edge.Source, edge.SourceHandle) } outgoing[edge.Source] = append(outgoing[edge.Source], edge) incoming[edge.Target]++ @@ -145,7 +145,7 @@ func validateRequiredHandles(nodes []RuleNode, handleTargets map[string]int) err for _, node := range nodes { for _, handle := range requiredHandles(node.Type) { if handleTargets[node.ID+"\x00"+handle] == 0 { - return fmt.Errorf("节点 %s 的 %s 出口未连接", node.ID, handle) + return fmt.Errorf("node %s output %s is not connected", node.ID, handle) } } } @@ -156,18 +156,18 @@ func validateRuleGraphConnectivity(nodes []RuleNode, startID string, outgoing ma reachable := walkRuleGraph(startID, outgoing) for _, node := range nodes { if !reachable[node.ID] { - return fmt.Errorf("节点 %s 无法从开始节点到达", node.ID) + return fmt.Errorf("node %s is not reachable from the start node", node.ID) } } for _, node := range nodes { if node.Type == RuleNodeStart && incoming[node.ID] != 0 { - return fmt.Errorf("开始节点 %s 不能有入边", node.ID) + return fmt.Errorf("start node %s must not have incoming edges", node.ID) } if node.Type != RuleNodeStart && incoming[node.ID] == 0 { - return fmt.Errorf("节点 %s 必须至少有一条入边", node.ID) + return fmt.Errorf("node %s must have at least one incoming edge", node.ID) } if (node.Type == RuleNodeAllow || node.Type == RuleNodeBlock) && len(outgoing[node.ID]) != 0 { - return fmt.Errorf("终止节点 %s 不能有出口", node.ID) + return fmt.Errorf("terminal node %s must not have outgoing edges", node.ID) } } return nil @@ -205,12 +205,12 @@ func validateIPMatchNodeConfig(ctx context.Context, node RuleNode, exists func(c } for _, raw := range cfg.IPs { if _, err := netip.ParseAddr(raw); err != nil { - return fmt.Errorf("节点 %s 的 IP %s 无效", node.ID, raw) + return fmt.Errorf("node %s contains invalid IP %s", node.ID, raw) } } for _, raw := range cfg.CIDRs { if _, err := netip.ParsePrefix(raw); err != nil { - return fmt.Errorf("节点 %s 的 CIDR %s 无效", node.ID, raw) + return fmt.Errorf("node %s contains invalid CIDR %s", node.ID, raw) } } for _, id := range cfg.IPGroupIDs { @@ -223,17 +223,17 @@ func validateIPMatchNodeConfig(ctx context.Context, node RuleNode, exists func(c func validateIPGroupReference(ctx context.Context, nodeID string, id uint, exists func(context.Context, uint) (bool, error)) error { if id == 0 { - return fmt.Errorf("节点 %s 引用的 IP 组 ID 无效", nodeID) + return fmt.Errorf("node %s references an invalid IP group ID", nodeID) } if exists == nil { - return fmt.Errorf("节点 %s 无法校验 IP 组 %d", nodeID, id) + return fmt.Errorf("node %s cannot validate IP group %d", nodeID, id) } ok, err := exists(ctx, id) if err != nil { - return fmt.Errorf("节点 %s 校验 IP 组 %d 失败: %w", nodeID, id, err) + return fmt.Errorf("node %s failed to validate IP group %d: %w", nodeID, id, err) } if !ok { - return fmt.Errorf("节点 %s 引用的 IP 组 %d 不存在", nodeID, id) + return fmt.Errorf("node %s references missing IP group %d", nodeID, id) } return nil } @@ -245,12 +245,12 @@ func validateGeoMatchNodeConfig(node RuleNode) error { } for _, code := range cfg.Countries { if !countryCodePattern.MatchString(code) { - return fmt.Errorf("节点 %s 的国家代码 %s 无效", node.ID, code) + return fmt.Errorf("node %s contains invalid country code %s", node.ID, code) } } for _, code := range cfg.Regions { if !regionCodePattern.MatchString(code) { - return fmt.Errorf("节点 %s 的地区代码 %s 无效", node.ID, code) + return fmt.Errorf("node %s contains invalid region code %s", node.ID, code) } } return nil @@ -262,16 +262,16 @@ func validatePoWNodeConfig(node RuleNode) error { return err } if cfg.Difficulty < 1 || cfg.Difficulty > 16 { - return fmt.Errorf("节点 %s 的 PoW 难度必须在 1-16 之间", node.ID) + return fmt.Errorf("PoW difficulty for node %s must be between 1 and 16", node.ID) } if cfg.Algorithm != powAlgorithmFast && cfg.Algorithm != powAlgorithmSlow { - return fmt.Errorf("节点 %s 的 PoW 算法必须为 fast 或 slow", node.ID) + return fmt.Errorf("PoW algorithm for node %s must be fast or slow", node.ID) } if cfg.SessionTTL < minPoWSessionTTLSeconds { - return fmt.Errorf("节点 %s 的 PoW 会话 TTL 不能小于 60 秒", node.ID) + return fmt.Errorf("PoW session TTL for node %s must be at least 60 seconds", node.ID) } if cfg.ChallengeTTL < minPoWChallengeTTLSeconds { - return fmt.Errorf("节点 %s 的 PoW 挑战 TTL 不能小于 30 秒", node.ID) + return fmt.Errorf("PoW challenge TTL for node %s must be at least 30 seconds", node.ID) } return nil } @@ -282,10 +282,10 @@ func validateBlockNodeConfig(node RuleNode) error { return err } if cfg.StatusCode < 400 || cfg.StatusCode > 599 { - return fmt.Errorf("节点 %s 的阻止状态码必须在 400-599 之间", node.ID) + return fmt.Errorf("block status code for node %s must be between 400 and 599", node.ID) } if len([]byte(cfg.ResponseBody)) > maxWAFBlockBodyBytes { - return fmt.Errorf("节点 %s 的阻止响应体不能超过 %d 字节", node.ID, maxWAFBlockBodyBytes) + return fmt.Errorf("block response body for node %s must not exceed %d bytes", node.ID, maxWAFBlockBodyBytes) } return nil } @@ -300,34 +300,34 @@ func validateUACheckNodeConfig(node RuleNode) error { mode = UACheckMatchModeOr } if mode != UACheckMatchModeAnd && mode != UACheckMatchModeOr { - return fmt.Errorf("节点 %s 的匹配模式必须为 and 或 or", node.ID) + return fmt.Errorf("match mode for node %s must be and or or", node.ID) } for _, label := range cfg.Browsers { if !uaBrowserLabels[label] { - return fmt.Errorf("节点 %s 的浏览器标签 %s 无效", node.ID, label) + return fmt.Errorf("node %s contains invalid browser tag %s", node.ID, label) } } for _, label := range cfg.OperatingSystems { if !uaOSLabels[label] { - return fmt.Errorf("节点 %s 的操作系统标签 %s 无效", node.ID, label) + return fmt.Errorf("node %s contains invalid operating system tag %s", node.ID, label) } } if len(cfg.CustomUAPatterns) > maxUACustomPatterns { - return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 条", node.ID, maxUACustomPatterns) + return fmt.Errorf("node %s must not contain more than %d custom User-Agent patterns", node.ID, maxUACustomPatterns) } for _, pattern := range cfg.CustomUAPatterns { if strings.TrimSpace(pattern) == "" { - return fmt.Errorf("节点 %s 的自定义 UA 正则不能为空", node.ID) + return fmt.Errorf("custom User-Agent pattern in node %s must not be empty", node.ID) } if len(pattern) > maxUACustomPatternBytes { - return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 字节", node.ID, maxUACustomPatternBytes) + return fmt.Errorf("custom User-Agent pattern in node %s must not exceed %d bytes", node.ID, maxUACustomPatternBytes) } if _, err := regexp.Compile(pattern); err != nil { - return fmt.Errorf("节点 %s 的自定义 UA 正则无效: %s", node.ID, pattern) + return fmt.Errorf("invalid custom User-Agent pattern in node %s: %s", node.ID, pattern) } } if cfg.BlockCustomUA && len(cfg.CustomUAPatterns) == 0 { - return fmt.Errorf("节点 %s 开启屏蔽自定义 UA 时至少需要一条正则", node.ID) + return fmt.Errorf("node %s requires at least one pattern when custom User-Agent blocking is enabled", node.ID) } return nil } @@ -350,7 +350,7 @@ var uaOSLabels = map[string]bool{ func decodeNodeConfig(node RuleNode, dst any) error { if err := decodeStrictConfig(node.Config, dst); err != nil { - return fmt.Errorf("节点 %s 的配置无效: %w", node.ID, err) + return fmt.Errorf("invalid configuration for node %s: %w", node.ID, err) } return nil } @@ -358,7 +358,7 @@ func decodeNodeConfig(node RuleNode, dst any) error { func decodeStrictConfig(raw json.RawMessage, dst any) error { trimmed := bytes.TrimSpace(raw) if bytes.Equal(trimmed, []byte("null")) { - return errors.New("配置不能为 null") + return errors.New("configuration must not be null") } if len(trimmed) == 0 { raw = json.RawMessage(`{}`) @@ -369,7 +369,7 @@ func decodeStrictConfig(raw json.RawMessage, dst any) error { return err } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return errors.New("配置包含额外 JSON 值") + return errors.New("configuration contains an additional JSON value") } return nil } @@ -451,7 +451,7 @@ func validateTerminalPaths(nodes []RuleNode, outgoing map[string][]RuleEdge) err } for _, node := range nodes { if !terminating[node.ID] { - return fmt.Errorf("节点 %s 不存在通往终止节点的路径", node.ID) + return fmt.Errorf("node %s has no path to a terminal node", node.ID) } } return nil diff --git a/internal/apps/openflare/waf/graph_validate_test.go b/internal/apps/openflare/waf/graph_validate_test.go index a5cf4356..dc449412 100644 --- a/internal/apps/openflare/waf/graph_validate_test.go +++ b/internal/apps/openflare/waf/graph_validate_test.go @@ -43,71 +43,71 @@ func TestValidateRuleGraph(t *testing.T) { }{ {"duplicate start", func(g *RuleGraph) { g.Nodes = append(g.Nodes, RuleNode{ID: "start-2", Type: RuleNodeStart, Config: rawConfig(`{}`)}) - }, "恰好包含一个开始节点"}, + }, "rule graph must contain exactly one start node"}, {"duplicate allow", func(g *RuleGraph) { g.Nodes = append(g.Nodes, RuleNode{ID: "allow-2", Type: RuleNodeAllow, Config: rawConfig(`{}`)}) - }, "恰好包含一个通过节点"}, + }, "rule graph must contain exactly one allow node"}, {"cycle", func(g *RuleGraph) { g.Edges[2].Target = "start" - }, "规则图不能包含循环"}, + }, "rule graph must not contain cycles"}, {"unreachable", func(g *RuleGraph) { g.Nodes = append(g.Nodes, RuleNode{ID: "orphan", Type: RuleNodeBlock, Config: rawConfig(`{"status_code":403,"response_body":""}`)}) - }, "节点 orphan 无法从开始节点到达"}, - {"missing false edge", func(g *RuleGraph) { g.Edges = g.Edges[:2] }, "节点 match-1 的 false 出口未连接"}, - {"wrong handle", func(g *RuleGraph) { g.Edges[0].SourceHandle = "true" }, "边 e1 的源端口 true 不适用于节点 start"}, + }, "node orphan is not reachable from the start node"}, + {"missing false edge", func(g *RuleGraph) { g.Edges = g.Edges[:2] }, "node match-1 output false is not connected"}, + {"wrong handle", func(g *RuleGraph) { g.Edges[0].SourceHandle = "true" }, "edge e1 source port true is not valid for node start"}, {"same handle multiple targets", func(g *RuleGraph) { g.Edges = append(g.Edges, RuleEdge{ID: "e4", Source: "match-1", SourceHandle: "true", Target: "allow"}) - }, "节点 match-1 的 true 出口连接了多个目标"}, - {"unknown type", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodeType("script") }, "节点 match-1 的类型 script 未知"}, - {"dangling target", func(g *RuleGraph) { g.Edges[1].Target = "missing" }, "边 e2 的目标节点 missing 不存在"}, - {"invalid ip", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"ips":["bad"]}`) }, "节点 match-1 的 IP bad 无效"}, - {"invalid cidr", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"cidrs":["bad"]}`) }, "节点 match-1 的 CIDR bad 无效"}, - {"missing ip group", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"ip_group_ids":[99]}`) }, "节点 match-1 引用的 IP 组 99 不存在"}, + }, "node match-1 output true is connected to multiple targets"}, + {"unknown type", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodeType("script") }, "node match-1 has unknown type script"}, + {"dangling target", func(g *RuleGraph) { g.Edges[1].Target = "missing" }, "edge e2 references missing target node missing"}, + {"invalid ip", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"ips":["bad"]}`) }, "node match-1 contains invalid IP bad"}, + {"invalid cidr", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"cidrs":["bad"]}`) }, "node match-1 contains invalid CIDR bad"}, + {"missing ip group", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"ip_group_ids":[99]}`) }, "node match-1 references missing IP group 99"}, {"invalid country", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodeGeoMatch g.Nodes[1].Config = rawConfig(`{"countries":["USA"]}`) - }, "节点 match-1 的国家代码 USA 无效"}, + }, "node match-1 contains invalid country code USA"}, {"invalid region", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodeGeoMatch g.Nodes[1].Config = rawConfig(`{"regions":["US-"]}`) - }, "节点 match-1 的地区代码 US- 无效"}, + }, "node match-1 contains invalid region code US-"}, {"pow range", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodePoW g.Nodes[1].Config = rawConfig(`{"algorithm":"fast","difficulty":17,"session_ttl":60,"challenge_ttl":30}`) g.Edges[1].SourceHandle = "next" g.Edges = g.Edges[:2] - }, "节点 match-1 的 PoW 难度必须在 1-16 之间"}, + }, "PoW difficulty for node match-1 must be between 1 and 16"}, {"invalid ua browser", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodeUACheck g.Nodes[1].Config = rawConfig(`{"browsers":["NotABrowser"],"match_mode":"or"}`) - }, "节点 match-1 的浏览器标签 NotABrowser 无效"}, + }, "node match-1 contains invalid browser tag NotABrowser"}, {"invalid ua match mode", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodeUACheck g.Nodes[1].Config = rawConfig(`{"match_mode":"xor"}`) - }, "节点 match-1 的匹配模式必须为 and 或 or"}, + }, "match mode for node match-1 must be and or or"}, {"invalid ua custom regex", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodeUACheck g.Nodes[1].Config = rawConfig(`{"block_custom_ua":true,"custom_ua_patterns":["("]}`) - }, "节点 match-1 的自定义 UA 正则无效"}, + }, "invalid custom User-Agent pattern in node match-1"}, {"custom ua requires patterns", func(g *RuleGraph) { g.Nodes[1].Type = RuleNodeUACheck g.Nodes[1].Config = rawConfig(`{"block_custom_ua":true}`) - }, "节点 match-1 开启屏蔽自定义 UA 时至少需要一条正则"}, - {"unknown config field", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"ips":[],"surprise":true}`) }, "节点 match-1 的配置无效"}, - {"null config", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`null`) }, "节点 match-1 的配置无效"}, + }, "node match-1 requires at least one pattern when custom User-Agent blocking is enabled"}, + {"unknown config field", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"ips":[],"surprise":true}`) }, "invalid configuration for node match-1"}, + {"null config", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`null`) }, "invalid configuration for node match-1"}, {"too many nodes", func(g *RuleGraph) { for i := 0; i < 125; i++ { g.Nodes = append(g.Nodes, RuleNode{ID: strings.Repeat("x", i+1), Type: RuleNodeBlock, Config: rawConfig(`{"status_code":403}`)}) } - }, "规则图节点数不能超过 128"}, + }, "rule graph must not contain more than 128 nodes"}, {"too many edges", func(g *RuleGraph) { for i := 0; i < 254; i++ { g.Edges = append(g.Edges, RuleEdge{ID: strings.Repeat("e", i+5), Source: "start", SourceHandle: "next", Target: "allow"}) } - }, "规则图边数不能超过 256"}, + }, "rule graph must not contain more than 256 edges"}, {"graph too large", func(g *RuleGraph) { g.Nodes[0].Label = strings.Repeat("x", 256*1024) - }, "规则图大小不能超过 256 KiB"}, + }, "rule graph must not exceed 256 KiB"}, } for _, tt := range tests { @@ -127,7 +127,7 @@ func TestValidateRuleGraphDetectsNodeWithoutTerminalPath(t *testing.T) { nodes := []RuleNode{{ID: "start", Type: RuleNodeStart}, {ID: "match", Type: RuleNodeIPMatch}, {ID: "allow", Type: RuleNodeAllow}} outgoing := map[string][]RuleEdge{"start": {{Source: "start", Target: "match"}}} err := validateTerminalPaths(nodes, outgoing) - if err == nil || !strings.Contains(err.Error(), "节点 start 不存在通往终止节点的路径") { + if err == nil || !strings.Contains(err.Error(), "node start has no path to a terminal node") { t.Fatalf("validateTerminalPaths() error = %v", err) } } diff --git a/internal/apps/openflare/waf/ip_group_sync.go b/internal/apps/openflare/waf/ip_group_sync.go index 53d55a14..27e6a6e9 100644 --- a/internal/apps/openflare/waf/ip_group_sync.go +++ b/internal/apps/openflare/waf/ip_group_sync.go @@ -150,7 +150,7 @@ func SyncDueWAFIPGroups(ctx context.Context) error { func syncOpenFlareWAFIPGroup(ctx context.Context, group *model.OpenFlareWAFIPGroup, now time.Time) (*IPGroupSyncResult, error) { if group == nil { - return nil, errors.New("IP 组不存在") + return nil, errors.New("IP group was not found") } switch group.Type { case wafIPGroupTypeSubscription: @@ -158,7 +158,7 @@ func syncOpenFlareWAFIPGroup(ctx context.Context, group *model.OpenFlareWAFIPGro case wafIPGroupTypeAutomatic: return syncIPGroupAutomatic(ctx, group, now) default: - return nil, &RuleValidationError{Err: errors.New("只有自动和订阅类型 IP 组支持同步")} + return nil, &RuleValidationError{Err: errors.New("only automatic and subscription IP groups support synchronization")} } } @@ -179,7 +179,7 @@ func syncIPGroupSubscription(ctx context.Context, group *model.OpenFlareWAFIPGro group.LastSyncedAt = &now group.NextSyncAt = &nextSyncAt group.LastSyncStatus = "success" - group.LastSyncMessage = fmt.Sprintf("同步成功,共 %d 条 IP/IP 段", len(ips)) + group.LastSyncMessage = fmt.Sprintf("Synchronization completed; %d IP addresses or ranges", len(ips)) if err := repository.UpdateOpenFlareWAFIPGroupSyncResult(ctx, group); err != nil { return nil, err } @@ -265,7 +265,7 @@ func syncIPGroupAutomatic(ctx context.Context, group *model.OpenFlareWAFIPGroup, group.LastSyncedAt = &now group.NextSyncAt = &nextSyncAt group.LastSyncStatus = "success" - group.LastSyncMessage = fmt.Sprintf("自动规则执行成功,共命中 %d 个 IP,当前生效 %d 个 IP", len(ips), len(finalIPs)) + group.LastSyncMessage = fmt.Sprintf("Automatic rules completed; matched %d IPs, currently active %d IPs", len(ips), len(finalIPs)) if err := repository.UpdateOpenFlareWAFIPGroupSyncResult(ctx, group); err != nil { return nil, err } @@ -301,7 +301,7 @@ func evaluateParsedIPGroupAutoConfig(ctx context.Context, config ipGroupAutoConf for i, rule := range config.Rules { program, err := exprlang.Compile(rule.Expr, exprlang.Env(ipGroupAutoRuleEnv{}), exprlang.AsBool()) if err != nil { - return nil, fmt.Errorf("自动规则 %s Expr 无效: %w", displayIPGroupAutoRuleName(rule, i), err) + return nil, fmt.Errorf("invalid expression in automatic rule %s: %w", displayIPGroupAutoRuleName(rule, i), err) } programs = append(programs, program) } @@ -348,7 +348,7 @@ func evaluateParsedIPGroupAutoConfig(ctx context.Context, config ipGroupAutoConf for _, program := range programs { output, err := exprlang.Run(program, env) if err != nil { - return nil, fmt.Errorf("执行自动规则失败: %w", err) + return nil, fmt.Errorf("failed to execute automatic rule: %w", err) } if matchedRule, ok := output.(bool); ok && matchedRule { matched = append(matched, acc.ip) @@ -409,23 +409,23 @@ func downloadIPGroupSubscription(ctx context.Context, rawURL string) ([]byte, er client := http.Client{Timeout: 15 * time.Second} req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { - return nil, fmt.Errorf("下载订阅失败: %w", err) + return nil, fmt.Errorf("failed to download subscription: %w", err) } resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("下载订阅失败: %w", err) + return nil, fmt.Errorf("failed to download subscription: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("订阅返回状态码 %d", resp.StatusCode) + return nil, fmt.Errorf("subscription returned status code %d", resp.StatusCode) } var buffer bytes.Buffer reader := io.LimitReader(resp.Body, maxWAFIPGroupSubscriptionBytes+1) if _, err := buffer.ReadFrom(reader); err != nil { - return nil, fmt.Errorf("读取订阅内容失败: %w", err) + return nil, fmt.Errorf("failed to read subscription content: %w", err) } if buffer.Len() > maxWAFIPGroupSubscriptionBytes { - return nil, fmt.Errorf("订阅内容不能超过 %d 字节", maxWAFIPGroupSubscriptionBytes) + return nil, fmt.Errorf("subscription content must not exceed %d bytes", maxWAFIPGroupSubscriptionBytes) } return buffer.Bytes(), nil } @@ -459,7 +459,7 @@ func parseIPGroupTextSubscription(text string) []string { func parseIPGroupJSONSubscription(content []byte, mappingRule string) ([]string, error) { var payload any if err := json.Unmarshal(content, &payload); err != nil { - return nil, fmt.Errorf("JSON 订阅解析失败: %w", err) + return nil, fmt.Errorf("failed to parse JSON subscription: %w", err) } nodes, err := selectJSONMappingNodes(payload, mappingRule) if err != nil { @@ -470,7 +470,7 @@ func parseIPGroupJSONSubscription(content []byte, mappingRule string) ([]string, collectJSONStrings(node, &items) } if len(items) == 0 { - return nil, errors.New("JSON 订阅没有解析到 IP/IP 段") + return nil, errors.New("no IP addresses or ranges were parsed from the JSON subscription") } return items, nil } @@ -512,7 +512,7 @@ func selectJSONMappingNodes(payload any, mappingRule string) ([]any, error) { nodes = next } if len(nodes) == 0 { - return nil, fmt.Errorf("JSON 映射规则 %q 未匹配到内容", mappingRule) + return nil, fmt.Errorf("JSON mapping rule %q did not match any content", mappingRule) } return nodes, nil } diff --git a/internal/apps/openflare/waf/logics.go b/internal/apps/openflare/waf/logics.go index 499e182a..ffd831d6 100644 --- a/internal/apps/openflare/waf/logics.go +++ b/internal/apps/openflare/waf/logics.go @@ -224,7 +224,7 @@ func EnsureDefaultRuleGroup(ctx context.Context) error { return marshalErr } group := &model.OpenFlareWAFRuleGroup{ - Name: "全局规则组", Enabled: true, IsGlobal: true, Graph: string(graph), Revision: 1, + Name: "Global rule group", Enabled: true, IsGlobal: true, Graph: string(graph), Revision: 1, } return repository.CreateOpenFlareWAFRuleGroup(ctx, group) } @@ -308,7 +308,7 @@ func DeleteIPGroup(ctx context.Context, id uint) error { return err } if counts[group.ID] > 0 { - return &RuleValidationError{Err: errors.New("IP 组已被 WAF 规则引用,请先移除引用")} + return &RuleValidationError{Err: errors.New("IP group is referenced by WAF rules; remove the references first")} } return repository.DeleteOpenFlareWAFIPGroup(ctx, group.ID) } @@ -412,13 +412,13 @@ func normalizeIPList(items []string) ([]string, error) { if strings.Contains(item, "/") { prefix, err := netip.ParsePrefix(item) if err != nil { - return nil, fmt.Errorf("%s 不是合法 IP 段", item) + return nil, fmt.Errorf("%s is not a valid IP range", item) } item = prefix.Masked().String() } else { addr, err := netip.ParseAddr(item) if err != nil { - return nil, fmt.Errorf("%s 不是合法 IP", item) + return nil, fmt.Errorf("%s is not a valid IP address", item) } item = addr.String() } @@ -446,10 +446,10 @@ func normalizeRuleGroupIDs(ctx context.Context, groupIDs []uint) ([]uint, error) for _, groupID := range normalized { group, err := repository.GetOpenFlareWAFRuleGroupByID(ctx, groupID) if err != nil { - return nil, fmt.Errorf("WAF 规则组 %d 不存在", groupID) + return nil, fmt.Errorf("WAF rule group %d was not found", groupID) } if group.IsGlobal { - return nil, errors.New("全局 WAF 规则组不需要手动绑定") + return nil, errors.New("global WAF rule groups do not require manual binding") } } return normalized, nil @@ -504,11 +504,11 @@ func parseIPGroupAutoConfig(raw json.RawMessage) (ipGroupAutoConfig, error) { } var object map[string]any if err := json.Unmarshal([]byte(text), &object); err != nil || object == nil { - return ipGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象") + return ipGroupAutoConfig{}, errors.New("automatic IP group configuration must be a JSON object") } var config ipGroupAutoConfig if err := json.Unmarshal([]byte(text), &config); err != nil { - return ipGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象") + return ipGroupAutoConfig{}, errors.New("automatic IP group configuration must be a JSON object") } lookbackDur, lookbackText, err := resolveIPGroupAutoLookback(object) if err != nil { @@ -526,10 +526,10 @@ func parseIPGroupAutoConfig(raw json.RawMessage) (ipGroupAutoConfig, error) { rule.Name = strings.TrimSpace(rule.Name) rule.Expr = strings.TrimSpace(rule.Expr) if rule.Expr == "" { - return ipGroupAutoConfig{}, fmt.Errorf("自动规则 %d 的 Expr 表达式不能为空", i+1) + return ipGroupAutoConfig{}, fmt.Errorf("expression in automatic rule %d must not be empty", i+1) } if _, err := exprlang.Compile(rule.Expr, exprlang.Env(ipGroupAutoRuleEnv{}), exprlang.AsBool()); err != nil { - return ipGroupAutoConfig{}, fmt.Errorf("自动规则 %s Expr 无效: %w", displayIPGroupAutoRuleName(rule, i), err) + return ipGroupAutoConfig{}, fmt.Errorf("invalid expression in automatic rule %s: %w", displayIPGroupAutoRuleName(rule, i), err) } config.Rules[i] = rule } @@ -549,14 +549,14 @@ func resolveIPGroupAutoLookback(object map[string]any) (time.Duration, string, e // legacy: minutes as number or numeric string minutes, err := parsePositiveNumber(raw) if err != nil { - return 0, "", fmt.Errorf("lookback_minutes 无效: %w", err) + return 0, "", fmt.Errorf("invalid lookback_minutes: %w", err) } if minutes <= 0 { return defaultWAFIPGroupAutoLookbackDur, defaultWAFIPGroupAutoLookback, nil } dur := time.Duration(minutes) * time.Minute if dur > maxWAFIPGroupAutoLookback { - return 0, "", fmt.Errorf("回看窗口不能超过 %s", formatLookbackDuration(maxWAFIPGroupAutoLookback)) + return 0, "", fmt.Errorf("lookback window must not exceed %s", formatLookbackDuration(maxWAFIPGroupAutoLookback)) } return dur, formatLookbackDuration(dur), nil } @@ -574,20 +574,20 @@ func parseIPGroupLookbackValue(raw any) (time.Duration, string, error) { if isAllDigits(trimmed) { minutes, err := parsePositiveNumber(trimmed) if err != nil || minutes <= 0 { - return 0, "", errors.New("lookback 格式不合法,请使用 60m、1h 等时长") + return 0, "", errors.New("invalid lookback format; use a duration such as 60m or 1h") } dur := time.Duration(minutes) * time.Minute if dur > maxWAFIPGroupAutoLookback { - return 0, "", fmt.Errorf("回看窗口不能超过 %s", formatLookbackDuration(maxWAFIPGroupAutoLookback)) + return 0, "", fmt.Errorf("lookback window must not exceed %s", formatLookbackDuration(maxWAFIPGroupAutoLookback)) } return dur, formatLookbackDuration(dur), nil } dur, err := time.ParseDuration(strings.ToLower(trimmed)) if err != nil || dur <= 0 { - return 0, "", errors.New("lookback 格式不合法,请使用 60m、1h 等时长") + return 0, "", errors.New("invalid lookback format; use a duration such as 60m or 1h") } if dur > maxWAFIPGroupAutoLookback { - return 0, "", fmt.Errorf("回看窗口不能超过 %s", formatLookbackDuration(maxWAFIPGroupAutoLookback)) + return 0, "", fmt.Errorf("lookback window must not exceed %s", formatLookbackDuration(maxWAFIPGroupAutoLookback)) } return dur, formatLookbackDuration(dur), nil case float64: @@ -595,17 +595,17 @@ func parseIPGroupLookbackValue(raw any) (time.Duration, string, error) { return defaultWAFIPGroupAutoLookbackDur, defaultWAFIPGroupAutoLookback, nil } if v != float64(int64(v)) { - return 0, "", errors.New("lookback 数值必须为整数分钟") + return 0, "", errors.New("lookback must be a whole number of minutes") } dur := time.Duration(int64(v)) * time.Minute if dur > maxWAFIPGroupAutoLookback { - return 0, "", fmt.Errorf("回看窗口不能超过 %s", formatLookbackDuration(maxWAFIPGroupAutoLookback)) + return 0, "", fmt.Errorf("lookback window must not exceed %s", formatLookbackDuration(maxWAFIPGroupAutoLookback)) } return dur, formatLookbackDuration(dur), nil case json.Number: return parseIPGroupLookbackValue(string(v)) default: - return 0, "", errors.New("lookback 格式不合法,请使用 60m、1h 等时长") + return 0, "", errors.New("invalid lookback format; use a duration such as 60m or 1h") } } @@ -613,7 +613,7 @@ func parsePositiveNumber(raw any) (int, error) { switch v := raw.(type) { case float64: if v != float64(int(v)) { - return 0, errors.New("必须为整数") + return 0, errors.New("must be an integer") } return int(v), nil case int: @@ -629,7 +629,7 @@ func parsePositiveNumber(raw any) (int, error) { case string: trimmed := strings.TrimSpace(v) if trimmed == "" || !isAllDigits(trimmed) { - return 0, errors.New("必须为整数") + return 0, errors.New("must be an integer") } n := 0 for _, ch := range trimmed { @@ -637,7 +637,7 @@ func parsePositiveNumber(raw any) (int, error) { } return n, nil default: - return 0, errors.New("必须为整数") + return 0, errors.New("must be an integer") } } @@ -673,10 +673,10 @@ func formatLookbackDuration(d time.Duration) string { func validateSubscriptionURL(rawURL string) error { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Host == "" { - return errors.New("订阅 URL 无效") + return errors.New("invalid subscription URL") } if parsed.Scheme != "http" && parsed.Scheme != "https" { - return errors.New("订阅 URL 仅支持 http 或 https") + return errors.New("subscription URL must use HTTP or HTTPS") } return nil } @@ -729,11 +729,11 @@ func nextIPGroupSyncAt(groupType string, enabled bool, interval int, current *ti func buildIPGroup(group *model.OpenFlareWAFIPGroup, input IPGroupInput) (*model.OpenFlareWAFIPGroup, error) { name := strings.TrimSpace(input.Name) if name == "" { - return nil, errors.New("IP 组名称不能为空") + return nil, errors.New("IP group name must not be empty") } groupType := normalizeIPGroupType(input.Type) if groupType == "" { - return nil, errors.New("IP 组类型无效") + return nil, errors.New("invalid IP group type") } ipList := input.IPList subscriptionURL := "" diff --git a/internal/apps/openflare/waf/rule_logics.go b/internal/apps/openflare/waf/rule_logics.go index c128f293..97473a44 100644 --- a/internal/apps/openflare/waf/rule_logics.go +++ b/internal/apps/openflare/waf/rule_logics.go @@ -96,7 +96,7 @@ func GetRule(ctx context.Context, id uint) (*RuleView, error) { func CreateRule(ctx context.Context, input CreateRuleInput) (*RuleView, error) { name := strings.TrimSpace(input.Name) if name == "" { - return nil, &RuleValidationError{Err: errors.New("WAF 规则名称不能为空")} + return nil, &RuleValidationError{Err: errors.New("WAF rule name must not be empty")} } raw, err := json.Marshal(DefaultRuleGraph()) if err != nil { @@ -123,7 +123,7 @@ func UpdateRuleMeta(ctx context.Context, id uint, input UpdateRuleMetaInput) (*R } name := strings.TrimSpace(input.Name) if name == "" { - return nil, &RuleValidationError{Err: errors.New("WAF 规则名称不能为空")} + return nil, &RuleValidationError{Err: errors.New("WAF rule name must not be empty")} } group.Name, group.Enabled = name, input.Enabled if err = repository.UpdateOpenFlareWAFRuleGroup(ctx, group); err != nil { @@ -139,7 +139,7 @@ func DeleteRuleGroup(ctx context.Context, id uint) error { return err } if group.IsGlobal { - return &RuleValidationError{Err: errors.New("全局 WAF 规则不能删除")} + return &RuleValidationError{Err: errors.New("global WAF rule cannot be deleted")} } return repository.DeleteOpenFlareWAFRuleGroupWithBindings(ctx, id) } @@ -150,7 +150,7 @@ func SaveRuleGraph(ctx context.Context, id uint, input SaveRuleGraphInput) (*Rul return nil, err } if err := ValidateRuleGraph(ctx, input.Graph, ruleIPGroupExists); err != nil { - return nil, &RuleValidationError{Err: fmt.Errorf("规则图无效: %w", err)} + return nil, &RuleValidationError{Err: fmt.Errorf("invalid rule graph: %w", err)} } raw, err := json.Marshal(input.Graph) if err != nil { diff --git a/internal/apps/openflare/waf/rule_logics_test.go b/internal/apps/openflare/waf/rule_logics_test.go index b4376b83..62a25978 100644 --- a/internal/apps/openflare/waf/rule_logics_test.go +++ b/internal/apps/openflare/waf/rule_logics_test.go @@ -46,7 +46,7 @@ func TestDeleteIPGroupRejectsGraphReference(t *testing.T) { view, err := GetIPGroup(ctx, group.ID) require.NoError(t, err) assert.Equal(t, 1, view.ReferencedByRuleCount) - require.ErrorContains(t, DeleteIPGroup(ctx, group.ID), "已被 WAF 规则引用") + require.ErrorContains(t, DeleteIPGroup(ctx, group.ID), "referenced by WAF rules") } func TestRuleHandlersMapFailures(t *testing.T) { @@ -129,7 +129,7 @@ func TestCreateRuleRejectsEmptyName(t *testing.T) { defer cleanup() _, err := CreateRule(context.Background(), CreateRuleInput{Name: " "}) - require.ErrorContains(t, err, "名称不能为空") + require.ErrorContains(t, err, "name must not be empty") } func TestSaveRuleGraphValidationAndRevisionConflict(t *testing.T) { diff --git a/internal/apps/openflare/waf/rule_routers.go b/internal/apps/openflare/waf/rule_routers.go index cf3fd1f4..ce50d58b 100644 --- a/internal/apps/openflare/waf/rule_routers.go +++ b/internal/apps/openflare/waf/rule_routers.go @@ -24,12 +24,12 @@ func handleRuleError(c *gin.Context, err error) bool { case errors.As(err, &validation): response.AbortBadRequest(c, validation.Error()) case errors.Is(err, model.ErrWAFRuleRevisionConflict): - response.AbortConflict(c, "规则已被其他操作更新,请重新加载") + response.AbortConflict(c, "Rule was updated by another operation; reload it") case errors.Is(err, gorm.ErrRecordNotFound): - response.AbortNotFound(c, "WAF 规则不存在") + response.AbortNotFound(c, "WAF rule was not found") default: logger.ErrorF(c.Request.Context(), "[OpenFlareWAF] rule API failed: %v", err) - response.AbortInternal(c, "WAF 规则操作失败") + response.AbortInternal(c, "WAF rule operation failed") } return true } diff --git a/internal/apps/openflare/zone/errs.go b/internal/apps/openflare/zone/errs.go index 880c7591..5fef50f7 100644 --- a/internal/apps/openflare/zone/errs.go +++ b/internal/apps/openflare/zone/errs.go @@ -4,16 +4,16 @@ package zone const ( - errZoneDomainRequired = "域名不能为空" - errZoneRootInvalid = "zone 必须是有效的注册根域" - errDomainInvalid = "域名格式不合法" - errDomainWildcardUnsupported = "不支持通配符域名" - errDomainOutsideZone = "域名不属于该 Zone" - errZoneNotFound = "Zone 不存在" - errDomainNotFound = "域名不存在" - errDomainExists = "域名已存在" - errCertificateNotFound = "所选证书不存在" - errDomainBoundToRoute = "域名已绑定反代路由,请先解除绑定" - errZoneHasDomains = "根域下仍有域名,请先删除全部域名" - errStatsRangeInvalid = "时间范围无效,请选择 24h、7d 或 30d" + errZoneDomainRequired = "domain name must not be empty" + errZoneRootInvalid = "zone must be a valid registered root domain" + errDomainInvalid = "invalid domain name format" + errDomainWildcardUnsupported = "wildcard domain names are not supported" + errDomainOutsideZone = "domain does not belong to this zone" + errZoneNotFound = "Zone was not found" + errDomainNotFound = "Domain was not found" + errDomainExists = "domain already exists" + errCertificateNotFound = "selected certificate was not found" + errDomainBoundToRoute = "domain is bound to a proxy route; remove the binding first" + errZoneHasDomains = "root domain still contains domains; delete them all first" + errStatsRangeInvalid = "invalid time range; select 24h, 7d or 30d" ) diff --git a/internal/apps/openflare/zone/legacy_import.go b/internal/apps/openflare/zone/legacy_import.go index ed7c09f3..30d6fe84 100644 --- a/internal/apps/openflare/zone/legacy_import.go +++ b/internal/apps/openflare/zone/legacy_import.go @@ -25,7 +25,7 @@ type ImportReport struct { // LogAndReturn decorates the import failure with its conflict count. func (r ImportReport) LogAndReturn(err error) error { if err != nil { - return fmt.Errorf("迁移 Zone 失败(%d 个冲突): %w", len(r.Conflicts), err) + return fmt.Errorf("failed to migrate zone (%d conflicts): %w", len(r.Conflicts), err) } return nil } diff --git a/internal/apps/relay/config/config.go b/internal/apps/relay/config/config.go index 1d9ce32e..1f501a73 100644 --- a/internal/apps/relay/config/config.go +++ b/internal/apps/relay/config/config.go @@ -129,13 +129,13 @@ func applyDefaults(cfg *Config, baseDir string) { func validate(cfg *Config) error { if cfg.ServerURL == "" { - return errors.New("server_url 不能为空") + return errors.New("server_url must not be empty") } if strings.TrimSpace(cfg.AgentToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" { - return errors.New("agent_token 和 discovery_token 不能同时为空") + return errors.New("agent_token and discovery_token must not both be empty") } if cfg.NodeName == "" { - return errors.New("node_name 不能为空") + return errors.New("node_name must not be empty") } return nil } @@ -154,10 +154,10 @@ func (cfg *Config) InitialAuthToken() string { // Save writes the current configuration back to the loaded config path. func (cfg *Config) Save() error { if cfg == nil { - return errors.New("config 不能为空") + return errors.New("config must not be empty") } if cfg.configPath == "" { - return errors.New("config path 未初始化") + return errors.New("configuration path is not initialized") } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { diff --git a/internal/apps/upload/filesrv/file_server.go b/internal/apps/upload/filesrv/file_server.go index 113d81c1..41c88d8a 100644 --- a/internal/apps/upload/filesrv/file_server.go +++ b/internal/apps/upload/filesrv/file_server.go @@ -65,15 +65,15 @@ func ServeFileByID(c *gin.Context) { upload, err := GetUploadRecordByID(c) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, "文件记录未找到") + response.AbortNotFound(c, "File record was not found") return } var numErr *strconv.NumError if errors.As(err, &numErr) { - response.AbortBadRequest(c, "无效的上传ID") + response.AbortBadRequest(c, "Invalid upload ID") return } - response.AbortInternal(c, "服务器内部错误") + response.AbortInternal(c, "Internal server error") return } @@ -266,7 +266,7 @@ func ImageCompressionCacheKey(upload *model.Upload, quality string) string { func serveOriginal(c *gin.Context, upload *model.Upload) { obj, err := uploadstorage.OpenStoredObject(c.Request.Context(), upload) if err != nil { - response.AbortNotFound(c, "文件未找到") + response.AbortNotFound(c, "File was not found") return } defer func() { _ = obj.Body.Close() }() diff --git a/internal/apps/upload/handler/file_management.go b/internal/apps/upload/handler/file_management.go index 5ca3b7dd..8b6ffd3d 100644 --- a/internal/apps/upload/handler/file_management.go +++ b/internal/apps/upload/handler/file_management.go @@ -118,7 +118,7 @@ func DeleteFile(c *gin.Context) { return } if isRecordNotFound(err) { - response.AbortNotFound(c, "文件记录未找到") + response.AbortNotFound(c, "File record was not found") return } response.AbortBadRequest(c, shared.ErrDeleteFileFailed) @@ -244,11 +244,11 @@ func DeleteMyFile(c *gin.Context) { return } if isRecordNotFound(err) { - response.AbortNotFound(c, "文件记录未找到") + response.AbortNotFound(c, "File record was not found") return } if errors.Is(err, ingest.ErrForbidden) { - response.AbortForbidden(c, "无权操作") + response.AbortForbidden(c, "Operation not permitted") return } response.AbortBadRequest(c, shared.ErrDeleteFileFailed) @@ -298,14 +298,14 @@ func UpdateMyFile(c *gin.Context) { upload, err := updateOwnedUpload(ctx, currUser.ID, uploadID, updateMyUploadInput(req)) if err != nil { if isRecordNotFound(err) { - response.AbortNotFound(c, "文件记录未找到") + response.AbortNotFound(c, "File record was not found") return } if errors.Is(err, ingest.ErrForbidden) { - response.AbortForbidden(c, "无权操作") + response.AbortForbidden(c, "Operation not permitted") return } - response.AbortBadRequest(c, "更新文件记录失败") + response.AbortBadRequest(c, "Failed to update file record") return } diff --git a/internal/apps/upload/handler/routers.go b/internal/apps/upload/handler/routers.go index 0fafb443..c9098c69 100644 --- a/internal/apps/upload/handler/routers.go +++ b/internal/apps/upload/handler/routers.go @@ -175,7 +175,7 @@ func DownloadFile(c *gin.Context) { upload, err := filesrv.GetUploadRecordByID(c) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, "文件记录未找到") + response.AbortNotFound(c, "File record was not found") return } var numErr *strconv.NumError @@ -281,13 +281,13 @@ func BatchDownloadFiles(c *gin.Context) { zipFileEntry, err := zipWriter.Create(fileName) if err != nil { - logger.ErrorF(ctx, "ZIP 添加条目失败 [%s]: %v", fileName, err) + logger.ErrorF(ctx, "Failed to add ZIP entry [%s]: %v", fileName, err) continue } obj, err := uploadstorage.OpenStoredObject(ctx, &upload) if err != nil { - logger.ErrorF(ctx, "打包时读取文件失败: %v", err) + logger.ErrorF(ctx, "Failed to read file while creating archive: %v", err) continue } rc := obj.Body @@ -295,7 +295,7 @@ func BatchDownloadFiles(c *gin.Context) { _, err = io.Copy(zipFileEntry, rc) _ = rc.Close() if err != nil { - logger.ErrorF(ctx, "写入 ZIP 流失败: %v", err) + logger.ErrorF(ctx, "Failed to write ZIP stream: %v", err) } } } @@ -311,7 +311,7 @@ func resolveUploadAccessMode(c *gin.Context, uploadType string) (int, string) { accessMode, err := strconv.Atoi(accessModeStr) if err != nil || (accessMode != 0 && accessMode != 1) { - return 0, "无效的 access_mode 参数" + return 0, "Invalid access_mode parameter" } return accessMode, "" } diff --git a/internal/apps/upload/handler/stats.go b/internal/apps/upload/handler/stats.go index e14536bc..03170c62 100644 --- a/internal/apps/upload/handler/stats.go +++ b/internal/apps/upload/handler/stats.go @@ -8,6 +8,7 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" + uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" @@ -71,7 +72,7 @@ func GetFileStats(c *gin.Context) { categories []distributionItem ) - categoriesList := []string{"图片", "视频", "音频", "文档", "压缩包", "其他"} + categoriesList := uploadstats.Categories() categoryMap := make(map[string]distributionItem, len(categoriesList)) for _, cat := range categoriesList { categoryMap[cat] = distributionItem{Name: cat} diff --git a/internal/apps/upload/ingest/helpers.go b/internal/apps/upload/ingest/helpers.go index 175e6abe..88d31c16 100644 --- a/internal/apps/upload/ingest/helpers.go +++ b/internal/apps/upload/ingest/helpers.go @@ -86,13 +86,13 @@ func storeObject(ctx context.Context, objectKey string, reader io.Reader, size i driver, backend, err := objectstore.Active(ctx) if err != nil { - logger.ErrorF(ctx, "初始化活动存储失败: %v", err) + logger.ErrorF(ctx, "Failed to initialize active storage: %v", err) return "", errors.New(shared.ErrSaveFileFailed) } result, err := backend.Put(ctx, objectKey, reader, size, mimeType) if err != nil { - logger.ErrorF(ctx, "写入 %s 存储失败: %v", driver, err) + logger.ErrorF(ctx, "Failed to write to %s storage: %v", driver, err) return "", errors.New(shared.ErrSaveFileFailed) } @@ -117,7 +117,7 @@ func cleanupUnpersistedObject(ctx context.Context, objectKey string) { return } if err := backend.Delete(ctx, objectKey); err != nil { - logger.WarnF(ctx, "清理未写入数据库的上传对象失败: %v", err) + logger.WarnF(ctx, "Failed to clean up upload object without a database record: %v", err) } } @@ -152,7 +152,7 @@ func createDedupRecord(ctx context.Context, existing model.Upload, req Request) if err := persistUploadRecord(ctx, &newUpload, existing.FilePath, false); err != nil { return Result{}, err } - logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", newUpload.ID, existing.FilePath) + logger.InfoF(ctx, "Upload deduplication succeeded ID: %d, Path: %s", newUpload.ID, existing.FilePath) return Result{ Upload: newUpload, Created: true, diff --git a/internal/apps/upload/shared/errs.go b/internal/apps/upload/shared/errs.go index 7f031341..b1ca6a1d 100644 --- a/internal/apps/upload/shared/errs.go +++ b/internal/apps/upload/shared/errs.go @@ -7,36 +7,36 @@ package shared // 文件管理常量 const ( - ErrNoFileSelected = "请选择要上传的文件" - ErrUnsupportedFormat = "只支持 JPG、PNG、WEBP 格式的图片" - ErrProcessFileFailed = "处理文件失败" - ErrSaveFileFailed = "保存文件失败" - ErrOpenFileFailed = "打开文件失败" - ErrSaveUploadRecordFailed = "保存上传记录失败" - ErrGenericFileTooLarge = "文件大小不能超过 32MB" - ErrFileContentExtensionMismatch = "文件内容与扩展名不匹配,可能包含安全风险" - ErrFileValidationFailed = "文件校验失败" - ErrInvalidMetadataJSON = "元数据 JSON 格式不合法" - ErrInvalidFileID = "无效的文件 ID" - ErrQueryUploadRecordFailed = "查询文件记录失败" - ErrInvalidBatchDownloadRequest = "参数绑定失败,请传入有效的文件 ID 数组" - ErrInvalidIDValueFormat = "无效的 ID 值: %s" - ErrRetrieveUploadRecordsFailed = "检索文件记录失败" - ErrNoValidFilesForArchive = "没有找到任何有效的文件记录进行打包" - ErrInvalidParams = "参数错误" - ErrQueryFileCountFailed = "查询文件数量失败" - ErrQueryFileListFailed = "查询文件列表失败" - ErrDeleteFileFailed = "删除文件失败" - ErrReservedUploadType = "系统保留的文件类型不能通过通用文件接口操作" - ErrStorageReadOnly = "存储迁移维护中,当前仅允许读取文件" + ErrNoFileSelected = "Select a file to upload" + ErrUnsupportedFormat = "only JPG, PNG and WEBP images are supported" + ErrProcessFileFailed = "Failed to process file" + ErrSaveFileFailed = "failed to save file" + ErrOpenFileFailed = "Failed to open file" + ErrSaveUploadRecordFailed = "Failed to save upload record" + ErrGenericFileTooLarge = "File size must not exceed 32 MB" + ErrFileContentExtensionMismatch = "File content does not match its extension and may be unsafe" + ErrFileValidationFailed = "File validation failed" + ErrInvalidMetadataJSON = "Invalid metadata JSON format" + ErrInvalidFileID = "Invalid file ID" + ErrQueryUploadRecordFailed = "Failed to query file record" + ErrInvalidBatchDownloadRequest = "Failed to bind parameters; provide a valid array of file IDs" + ErrInvalidIDValueFormat = "Invalid ID value: %s" + ErrRetrieveUploadRecordsFailed = "Failed to retrieve file records" + ErrNoValidFilesForArchive = "No valid file records were found to archive" + ErrInvalidParams = "invalid parameters" + ErrQueryFileCountFailed = "Failed to query file count" + ErrQueryFileListFailed = "Failed to query file list" + ErrDeleteFileFailed = "Failed to delete file" + ErrReservedUploadType = "system-reserved file types cannot be managed through the generic file API" + ErrStorageReadOnly = "storage migration maintenance is active; files are read-only" ErrS3KeyRequired = "s3 key must not be empty" ErrS3KeyTooLongFormat = "s3 key exceeds maximum length of %d" ErrS3KeyStartsWithSlash = "s3 key must not start with /" ErrS3KeyContainsNullBytes = "s3 key must not contain null bytes" - ErrQueryUnusedUploadsFailed = "查询未使用的上传文件失败: %w" - ErrImageCacheWarmupPayloadRequired = "图片缓存预热参数不能为空" - ErrInvalidImageCacheWarmupPayload = "图片缓存预热参数格式无效: %w" - ErrInvalidImageCacheWarmupQuality = "图片质量仅支持 low、medium、high" - ErrParseImageCacheWarmupPayload = "解析图片缓存预热参数失败: %w" - ErrQueryImagesForCacheWarmup = "查询待预热图片失败: %w" + ErrQueryUnusedUploadsFailed = "failed to query unused uploads: %w" + ErrImageCacheWarmupPayloadRequired = "image cache warmup parameters must not be empty" + ErrInvalidImageCacheWarmupPayload = "invalid image cache warmup parameters: %w" + ErrInvalidImageCacheWarmupQuality = "image quality must be low, medium or high" + ErrParseImageCacheWarmupPayload = "failed to parse image cache warmup parameters: %w" + ErrQueryImagesForCacheWarmup = "failed to query images for cache warmup: %w" ) diff --git a/internal/apps/upload/stats/category.go b/internal/apps/upload/stats/category.go index 52fdc583..8cf9ae9e 100644 --- a/internal/apps/upload/stats/category.go +++ b/internal/apps/upload/stats/category.go @@ -11,6 +11,8 @@ import ( ) const ( + // These are persisted counter keys, not display labels. Keep existing keys + // stable; the frontend translates their presentation independently. catImage = "图片" catVideo = "视频" catAudio = "音频" @@ -19,6 +21,11 @@ const ( catOther = "其他" ) +// Categories returns the legacy persistent category keys in display order. +func Categories() []string { + return []string{catImage, catVideo, catAudio, catDocument, catArchive, catOther} +} + // GetFileCategory classifies a file by mime type and extension. func GetFileCategory(mimeType, ext string) string { mimeType = strings.ToLower(mimeType) diff --git a/internal/apps/upload/storage/storage_ops.go b/internal/apps/upload/storage/storage_ops.go index 892691ef..b3eaff41 100644 --- a/internal/apps/upload/storage/storage_ops.go +++ b/internal/apps/upload/storage/storage_ops.go @@ -15,7 +15,7 @@ import ( func ReadOnly(ctx context.Context) bool { state := LoadMigrationAccessState(ctx) if state.LoadErr != nil { - logger.ErrorF(ctx, "读取存储维护状态失败: %v", state.LoadErr) + logger.ErrorF(ctx, "Failed to read storage maintenance state: %v", state.LoadErr) return true } return state.ReadOnly diff --git a/internal/apps/upload/task/cleanup.go b/internal/apps/upload/task/cleanup.go index 84ff11de..bde83aad 100644 --- a/internal/apps/upload/task/cleanup.go +++ b/internal/apps/upload/task/cleanup.go @@ -33,8 +33,8 @@ const ( var SystemCleanupMeta = task.TaskMeta{ Type: TaskTypeSystemCleanup, AsynqTask: SystemCleanupTask, - Name: "系统垃圾清理", - Description: "定期清理未使用上传文件、历史推送记录和过期任务执行日志", + Name: "System cleanup", + Description: "Periodically remove unused uploads, historical notifications and expired task execution logs", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -56,12 +56,12 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas oneHourAgo := time.Now().Add(-1 * time.Hour) - task.AppendLog(ctx, "开始扫描未使用上传文件,阈值: %s", oneHourAgo.Format(time.RFC3339)) + task.AppendLog(ctx, "Scanning unused uploads, cutoff: %s", oneHourAgo.Format(time.RFC3339)) for { unusedUploads, err := repository.ListPendingUploadsOlderThan(ctx, lastID, oneHourAgo, batchSize) if err != nil { - task.AppendLog(ctx, "查询未使用的上传文件失败: %v", err) + task.AppendLog(ctx, "Failed to query unused uploads: %v", err) return nil, fmt.Errorf(shared.ErrQueryUnusedUploadsFailed, err) } @@ -69,7 +69,7 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas break } - task.AppendLog(ctx, "本批次找到 %d 个需要清理的上传文件", len(unusedUploads)) + task.AppendLog(ctx, "Found %d uploads to clean up in this batch", len(unusedUploads)) for _, u := range unusedUploads { totalProcessed++ @@ -88,7 +88,7 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas transitioned, removeErr = ingest.RemoveLockedTx(tx, &locked) return removeErr }); err != nil { - task.AppendLog(ctx, "清理上传文件失败 [ID:%d]: %v", u.ID, err) + task.AppendLog(ctx, "Failed to clean up upload [ID:%d]: %v", u.ID, err) lastID = u.ID continue } @@ -101,48 +101,48 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas } } - task.AppendLog(ctx, "开始清理历史推送审计日志,只保留最近7天数据...") + task.AppendLog(ctx, "Cleaning up notification audit history; retaining the last 7 days...") cutoff := time.Now().AddDate(0, 0, -7) pushHistoryCount, err := repository.CountPushHistoriesCreatedBefore(ctx, cutoff) switch { case err != nil: - task.AppendLog(ctx, "统计待清理的历史推送记录失败: %v", err) + task.AppendLog(ctx, "Failed to count notification history for cleanup: %v", err) case pushHistoryCount == 0: - task.AppendLog(ctx, "没有需要清理的历史推送记录 (截止时间: %s)", cutoff.Format("2006-01-02 15:04:05")) + task.AppendLog(ctx, "No notification history requires cleanup (cutoff: %s)", cutoff.Format("2006-01-02 15:04:05")) default: if _, delErr := repository.DeletePushHistoriesCreatedBefore(ctx, cutoff); delErr != nil { - task.AppendLog(ctx, "删除历史推送记录失败: %v", delErr) + task.AppendLog(ctx, "Failed to delete notification history: %v", delErr) } else { - task.AppendLog(ctx, "成功删除 %d 条历史推送记录 (截止时间: %s)", pushHistoryCount, cutoff.Format("2006-01-02 15:04:05")) + task.AppendLog(ctx, "Deleted %d notification history records (cutoff: %s)", pushHistoryCount, cutoff.Format("2006-01-02 15:04:05")) } } - task.AppendLog(ctx, "开始清理任务执行日志:高频任务保留最近3天,低频任务保留最近30天...") + task.AppendLog(ctx, "Cleaning up task execution logs: retaining 3 days for high-frequency tasks and 30 days for low-frequency tasks...") taskLogStats, err := repository.CleanupTaskExecutionLogs(ctx, time.Now()) if err != nil { - task.AppendLog(ctx, "清理任务执行日志失败: %v", err) - logger.ErrorF(ctx, "清理任务执行日志失败: %v", err) + task.AppendLog(ctx, "Failed to clean up task execution logs: %v", err) + logger.ErrorF(ctx, "Failed to clean up task execution logs: %v", err) } else { - task.AppendLog(ctx, "成功清理任务执行日志 %d 条(高频 %d 条,低频 %d 条)", + task.AppendLog(ctx, "Cleaned up %d task execution logs (%d high-frequency, %d low-frequency)", taskLogStats.HighFrequencyDeleted+taskLogStats.LowFrequencyDeleted, taskLogStats.HighFrequencyDeleted, taskLogStats.LowFrequencyDeleted, ) } - task.AppendLog(ctx, "开始清理过期日志(访问日志按当前日志库保留天数,性能指标按独立短留存)...") + task.AppendLog(ctx, "Cleaning up expired logs using log database retention and separate short metric retention...") summary, err := logstore.CleanupExpired(ctx) switch { case err != nil: - logger.ErrorF(ctx, "清理过期日志失败: %v", err) - task.AppendLog(ctx, "清理过期日志失败: %v", err) + logger.ErrorF(ctx, "Failed to clean up expired logs: %v", err) + task.AppendLog(ctx, "Failed to clean up expired logs: %v", err) case summary.Deleted == 0: - task.AppendLog(ctx, "没有需要清理的过期日志 (访问日志保留 %d 天,性能指标保留 %d 天)", summary.RetentionDays, summary.MetricRetentionDays) + task.AppendLog(ctx, "No expired logs require cleanup (access log retention: %d days; metric retention: %d days)", summary.RetentionDays, summary.MetricRetentionDays) default: - task.AppendLog(ctx, "日志清理完成:访问日志保留 %d 天,性能指标保留 %d 天,删除 %d 条", summary.RetentionDays, summary.MetricRetentionDays, summary.Deleted) + task.AppendLog(ctx, "Log cleanup completed: access log retention %d days, metric retention %d days, deleted %d records", summary.RetentionDays, summary.MetricRetentionDays, summary.Deleted) } - msg := fmt.Sprintf("系统清理完成。成功清理未使用的上传文件 %d/%d 个;清理历史推送审计日志 %d 条;清理任务执行日志 %d 条。", + msg := fmt.Sprintf("System cleanup completed. Removed %d/%d unused uploads, %d notification audit records and %d task execution logs.", totalDeleted, totalProcessed, pushHistoryCount, diff --git a/internal/apps/upload/task/rebuild_stats.go b/internal/apps/upload/task/rebuild_stats.go index 2d0b59cf..363f2f05 100644 --- a/internal/apps/upload/task/rebuild_stats.go +++ b/internal/apps/upload/task/rebuild_stats.go @@ -23,8 +23,8 @@ const ( var RebuildUploadStatsMeta = task.TaskMeta{ Type: TaskTypeRebuildUploadStats, AsynqTask: RebuildUploadStatsTask, - Name: "重算文件存储统计", - Description: "根据当前 w_uploads 活跃记录全量重建 w_upload_stats(总量、类型、分类、趋势)", + Name: "Rebuild file storage statistics", + Description: "Rebuild w_upload_stats from active w_uploads records, including totals, types, categories and trends", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -38,25 +38,25 @@ type RebuildUploadStatsHandler struct{} func (h *RebuildUploadStatsHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) { activeCount, err := repository.CountActiveUploads(ctx) if err != nil { - task.AppendLog(ctx, "统计活跃上传记录失败: %v", err) + task.AppendLog(ctx, "Failed to count active uploads: %v", err) return nil, fmt.Errorf("count active uploads: %w", err) } - task.AppendLog(ctx, "开始重算文件存储统计,活跃记录数: %d", activeCount) + task.AppendLog(ctx, "Rebuilding file storage statistics; active records: %d", activeCount) if err := uploadstats.RebuildUploadStats(ctx); err != nil { - task.AppendLog(ctx, "重算文件存储统计失败: %v", err) + task.AppendLog(ctx, "Failed to rebuild file storage statistics: %v", err) return nil, fmt.Errorf("rebuild upload stats: %w", err) } totalStat, err := repository.GetTotalUploadStat(ctx) if err != nil { - task.AppendLog(ctx, "读取总量统计失败: %v", err) + task.AppendLog(ctx, "Failed to read aggregate statistics: %v", err) return nil, fmt.Errorf("load total upload stats: %w", err) } msg := fmt.Sprintf( - "文件存储统计重算完成,活跃记录 %d 条,统计文件数 %d,总大小 %d 字节", + "File storage statistics rebuilt: %d active records, %d files, %d bytes total", activeCount, totalStat.FileCount, totalStat.FileSize, diff --git a/internal/apps/upload/task/storage_migration.go b/internal/apps/upload/task/storage_migration.go index e4153511..115ff547 100644 --- a/internal/apps/upload/task/storage_migration.go +++ b/internal/apps/upload/task/storage_migration.go @@ -38,8 +38,8 @@ const ( var StorageMigrationMeta = task.TaskMeta{ Type: TaskTypeStorageMigration, AsynqTask: StorageMigrationTask, - Name: "迁移文件存储", - Description: "将活动存储中的文件迁移到待切换的目标存储,迁移期间文件系统保持只读", + Name: "Migrate file storage", + Description: "Migrate files from active storage to the target backend; files remain read-only during migration", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -47,11 +47,11 @@ var StorageMigrationMeta = task.TaskMeta{ Params: []task.TaskParam{ { Name: "target", - Label: "目标存储配置 (JSON)", + Label: "Target storage configuration (JSON)", Type: "text", Required: true, Placeholder: `{"driver": "s3", "local": {"root": "."}, "s3": {"bucket": "my-bucket", ...}}`, - Description: "待迁移到的目标存储引擎完整配置 JSON 字符串", + Description: "Complete JSON configuration of the target storage backend", }, }, } @@ -89,7 +89,7 @@ func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*task.T return nil, fmt.Errorf("acquire migration lock: %w", err) } if !ok { - return nil, errors.New("另一个存储迁移任务正在运行中") + return nil, errors.New("another storage migration task is already running") } stopRenewal := make(chan struct{}) @@ -132,7 +132,7 @@ func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*task.T if err := objectstore.SaveActiveConfig(ctx, target); err != nil { return nil, fmt.Errorf("activate same-driver storage config: %w", err) } - message := fmt.Sprintf("存储配置已更新,活动存储保持为 %s", target.Driver) + message := fmt.Sprintf("Storage configuration updated; active storage remains %s", target.Driver) task.AppendLog(ctx, "%s", message) return &task.TaskResult{Message: message}, nil } @@ -145,7 +145,7 @@ func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*task.T if err := objectstore.SaveActiveConfig(ctx, target); err != nil { return nil, fmt.Errorf("activate empty storage config: %w", err) } - message := fmt.Sprintf("当前存储没有需要迁移的对象,活动存储已切换为 %s", target.Driver) + message := fmt.Sprintf("Current storage has no objects to migrate; active storage switched to %s", target.Driver) task.AppendLog(ctx, "%s", message) return &task.TaskResult{Message: message}, nil } @@ -159,7 +159,7 @@ func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*task.T return nil, fmt.Errorf("create target storage: %w", err) } - task.AppendLog(ctx, "开始存储迁移: %s -> %s,总对象数: %d", active.Driver, target.Driver, total) + task.AppendLog(ctx, "Starting storage migration: %s -> %s, total objects: %d", active.Driver, target.Driver, total) migrated, err := migrateObjects(ctx, sourceBackend, targetBackend, total) if err != nil { return nil, err @@ -168,7 +168,7 @@ func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*task.T if err := objectstore.SaveActiveConfig(ctx, target); err != nil { return nil, fmt.Errorf("activate target storage: %w", err) } - message := fmt.Sprintf("存储迁移完成,共迁移 %d 个对象,活动存储已切换为 %s", migrated, target.Driver) + message := fmt.Sprintf("Storage migration completed; migrated %d objects, active storage switched to %s", migrated, target.Driver) task.AppendLog(ctx, "%s", message) return &task.TaskResult{Message: message}, nil } @@ -201,19 +201,19 @@ func migrateObjects( return migrated.Load(), fmt.Errorf("storage migration canceled: %w", err) } - task.AppendLog(ctx, "正在查询待迁移对象批次,当前已完成迁移: %d/%d", migrated.Load(), total) + task.AppendLog(ctx, "Querying next migration batch; migrated: %d/%d", migrated.Load(), total) objects, err := repository.ListDistinctActiveStorageObjects(ctx, lastFilePath, batchSize) if err != nil { return migrated.Load(), fmt.Errorf("query source objects: %w", err) } if len(objects) == 0 { - task.AppendLog(ctx, "所有对象迁移完毕") + task.AppendLog(ctx, "All objects have been migrated") break } lastFilePath = objects[len(objects)-1].FilePath - task.AppendLog(ctx, "获取当前批次迁移对象,批次大小: %d,实际获取对象数: %d", batchSize, len(objects)) + task.AppendLog(ctx, "Fetched migration batch; requested size: %d, actual objects: %d", batchSize, len(objects)) var g errgroup.Group g.SetLimit(migrationConcurrency) @@ -233,7 +233,7 @@ func migrateObjects( return migrated.Load(), err } - task.AppendLog(ctx, "当前批次迁移完成。迁移进度: %d/%d", migrated.Load(), total) + task.AppendLog(ctx, "Current migration batch completed. Progress: %d/%d", migrated.Load(), total) } return migrated.Load(), nil } @@ -246,11 +246,11 @@ func migrateSingleObject( sha256HexLength int, ) error { if shouldSkipMigration(ctx, targetBackend, obj) { - task.AppendLog(ctx, "[跳过迁移] 目标存储已存在相同文件: %s", obj.FilePath) + task.AppendLog(ctx, "[Migration skipped] Identical file already exists in target storage: %s", obj.FilePath) return nil } - task.AppendLog(ctx, "[迁移开始] 正在从源存储读取文件: %s", obj.FilePath) + task.AppendLog(ctx, "[Migration started] Reading file from source storage: %s", obj.FilePath) source, err := sourceBackend.Get(ctx, obj.FilePath) if err != nil { if isNotFoundError(err) { @@ -258,7 +258,7 @@ func migrateSingleObject( } return fmt.Errorf("open source object %q: %w", obj.FilePath, err) } - task.AppendLog(ctx, "[传输中] 正在向目标存储上传文件: %s (大小: %d 字节, 类型: %s)", obj.FilePath, obj.FileSize, obj.MimeType) + task.AppendLog(ctx, "[Transfer] Uploading file to target storage: %s (size: %d bytes, type: %s)", obj.FilePath, obj.FileSize, obj.MimeType) targetResult, putErr := targetBackend.Put(ctx, obj.FilePath, source.Body, obj.FileSize, obj.MimeType) closeErr := source.Body.Close() if putErr != nil { @@ -269,7 +269,7 @@ func migrateSingleObject( } if len(obj.Hash) == sha256HexLength { - task.AppendLog(ctx, "[校验中] 正在对目标文件进行数据一致性校验 (SHA-256): %s", targetResult.Key) + task.AppendLog(ctx, "[Verification] Checking target file consistency (SHA-256): %s", targetResult.Key) targetObj, getErr := targetBackend.Get(ctx, targetResult.Key) if getErr != nil { return fmt.Errorf("retrieve target object for verification %q: %w", obj.FilePath, getErr) @@ -287,16 +287,16 @@ func migrateSingleObject( if computedHash != obj.Hash { return fmt.Errorf("integrity check failed for %q: got hash %s, want %s", obj.FilePath, computedHash, obj.Hash) } - task.AppendLog(ctx, "[校验通过] 文件一致性校验成功: %s", targetResult.Key) + task.AppendLog(ctx, "[Verified] File consistency check succeeded: %s", targetResult.Key) } if targetResult.Key != obj.FilePath { - task.AppendLog(ctx, "[更新数据库] 正在更新文件路径: %s -> %s", obj.FilePath, targetResult.Key) + task.AppendLog(ctx, "[Database update] Updating file path: %s -> %s", obj.FilePath, targetResult.Key) if err := repository.UpdateActiveUploadsFilePath(ctx, obj.FilePath, targetResult.Key); err != nil { return fmt.Errorf("update migrated object %q: %w", obj.FilePath, err) } } - task.AppendLog(ctx, "[迁移成功] 文件已完成迁移: %s", targetResult.Key) + task.AppendLog(ctx, "[Migration succeeded] File migration completed: %s", targetResult.Key) return nil } @@ -321,7 +321,7 @@ func markMissingMigrationObjectDeleted( filePath string, sourceErr error, ) error { - task.AppendLog(ctx, "警告: 源存储中物理文件不存在,标记为已删除并跳过: %s (错误: %v)", filePath, sourceErr) + task.AppendLog(ctx, "Warning: source file is missing; marking it deleted and skipping: %s (error: %v)", filePath, sourceErr) affectedUploads, err := repository.MarkActiveUploadsDeletedByFilePath(ctx, filePath) if err != nil { diff --git a/internal/apps/upload/task/storage_migration_task_test.go b/internal/apps/upload/task/storage_migration_task_test.go index 43a101d2..c712cf1f 100644 --- a/internal/apps/upload/task/storage_migration_task_test.go +++ b/internal/apps/upload/task/storage_migration_task_test.go @@ -270,7 +270,7 @@ func TestMigrationHandlerExecuteWithRedisLock(t *testing.T) { if err == nil { t.Fatal("Execute() succeeded when lock was held, want error") } - if !strings.Contains(err.Error(), "另一个存储迁移任务正在运行中") { + if !strings.Contains(err.Error(), "another storage migration task is already running") { t.Errorf("expected lock warning, got: %v", err) } diff --git a/internal/apps/upload/task/tasks.go b/internal/apps/upload/task/tasks.go index f21056ba..5569d15a 100644 --- a/internal/apps/upload/task/tasks.go +++ b/internal/apps/upload/task/tasks.go @@ -31,8 +31,8 @@ var warmImageCacheMu sync.Mutex var WarmImageCacheMeta = task.TaskMeta{ Type: TaskTypeWarmImageCache, AsynqTask: WarmImageCacheTask, - Name: "预热图片压缩缓存", - Description: "串行将文件管理中的图片转换为指定质量的 WebP 并写入永久缓存", + Name: "Warm up compressed image cache", + Description: "Sequentially convert managed images to WebP at the specified quality and store them in permanent cache", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -40,11 +40,11 @@ var WarmImageCacheMeta = task.TaskMeta{ Params: []task.TaskParam{ { Name: "quality", - Label: "图片质量", + Label: "Image quality", Type: "string", Required: true, Placeholder: "low / medium / high", - Description: "WebP 压缩质量,仅支持 low、medium、high", + Description: "WebP compression quality; must be low, medium or high", }, }, } @@ -82,7 +82,7 @@ func (h *WarmImageCacheHandler) ValidatePayload(payload []byte) ([]byte, error) func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { normalizedPayload, err := h.ValidatePayload(payload) if err != nil { - task.AppendLog(ctx, "图片缓存预热参数无效: %v", err) + task.AppendLog(ctx, "Invalid image cache warmup parameters: %v", err) return nil, err } @@ -91,7 +91,7 @@ func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*t return nil, fmt.Errorf(shared.ErrParseImageCacheWarmupPayload, err) } - task.AppendLog(ctx, "等待获取图片缓存预热执行锁,质量: %s", req.Quality) + task.AppendLog(ctx, "Waiting for image cache warmup lock, quality: %s", req.Quality) warmImageCacheMu.Lock() defer warmImageCacheMu.Unlock() @@ -105,7 +105,7 @@ func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*t var totalGenerated int var totalFailed int - task.AppendLog(ctx, "开始串行预热图片压缩缓存,质量: %s,每批: %d", req.Quality, batchSize) + task.AppendLog(ctx, "Starting sequential compressed image cache warmup, quality: %s, batch size: %d", req.Quality, batchSize) for { if err := ctx.Err(); err != nil { @@ -114,7 +114,7 @@ func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*t uploads, err := repository.ListActiveImageUploadsAfterID(ctx, lastID, batchSize) if err != nil { - task.AppendLog(ctx, "查询图片上传记录失败: %v", err) + task.AppendLog(ctx, "Failed to query image upload records: %v", err) return nil, fmt.Errorf(shared.ErrQueryImagesForCacheWarmup, err) } @@ -139,7 +139,7 @@ func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*t totalFailed++ batchFailed++ if totalFailed <= maxFailureLogs { - task.AppendLog(ctx, "图片处理失败 [ID:%d]: %v", upload.ID, err) + task.AppendLog(ctx, "Failed to process image [ID:%d]: %v", upload.ID, err) } continue } @@ -154,7 +154,7 @@ func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*t task.AppendLog( ctx, - "批次完成,末尾 ID: %d,生成: %d,命中: %d,失败: %d", + "Batch completed, last ID: %d, generated: %d, hits: %d, failed: %d", lastID, batchGenerated, batchCached, @@ -163,7 +163,7 @@ func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*t } msg := fmt.Sprintf( - "图片缓存预热完成,共处理 %d 张,生成 %d 张,命中 %d 张,失败 %d 张", + "Image cache warmup completed; processed %d, generated %d, hits %d, failed %d", totalProcessed, totalGenerated, totalCached, diff --git a/internal/apps/upload/task/tasks_test.go b/internal/apps/upload/task/tasks_test.go index 08ca1d1d..56ded1cf 100644 --- a/internal/apps/upload/task/tasks_test.go +++ b/internal/apps/upload/task/tasks_test.go @@ -141,7 +141,7 @@ func TestSystemCleanupHandler_Execute(t *testing.T) { // 验证结果 require.NoError(t, err) require.NotNil(t, result) - assert.Contains(t, result.Message, "系统清理完成。成功清理未使用的上传文件 2/2 个;清理历史推送审计日志 1 条;清理任务执行日志 1 条。") + assert.Contains(t, result.Message, "System cleanup completed. Removed 2/2 unused uploads, 1 notification audit records and 1 task execution logs.") // 验证数据库状态:pending 且超过1小时的应被标记为 deleted var pendingCount int64 @@ -216,7 +216,7 @@ func TestSystemCleanupHandler_ExecuteNoFiles(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - assert.Contains(t, result.Message, "系统清理完成。成功清理未使用的上传文件 0/0 个;清理历史推送审计日志 0 条;清理任务执行日志 0 条。") + assert.Contains(t, result.Message, "System cleanup completed. Removed 0/0 unused uploads, 0 notification audit records and 0 task execution logs.") } func TestSystemCleanupHandler_ImplementsTaskHandler(t *testing.T) { @@ -363,7 +363,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) { if result == nil { t.Fatal("Execute() result = nil, want non-nil") } - if result.Message != "图片缓存预热完成,共处理 2 张,生成 2 张,命中 0 张,失败 0 张" { + if result.Message != "Image cache warmup completed; processed 2, generated 2, hits 0, failed 0" { t.Errorf("Execute() message = %q, want generated summary", result.Message) } @@ -383,7 +383,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) { if err != nil { t.Fatalf("second Execute(%s) returned error: %v", payload, err) } - if secondResult.Message != "图片缓存预热完成,共处理 2 张,生成 0 张,命中 2 张,失败 0 张" { + if secondResult.Message != "Image cache warmup completed; processed 2, generated 0, hits 2, failed 0" { t.Errorf("second Execute() message = %q, want cache-hit summary", secondResult.Message) } } diff --git a/internal/apps/user/access_tokens.go b/internal/apps/user/access_tokens.go index 047ea420..47d189ef 100644 --- a/internal/apps/user/access_tokens.go +++ b/internal/apps/user/access_tokens.go @@ -156,7 +156,7 @@ func DeleteAccessToken(c *gin.Context) { return } - c.JSON(http.StatusOK, response.OK("删除成功")) + c.JSON(http.StatusOK, response.OK("Deleted successfully")) } // RotateAccessToken 轮换一个 AccessToken diff --git a/internal/apps/user/errs.go b/internal/apps/user/errs.go index d4251f90..ebc83aee 100644 --- a/internal/apps/user/errs.go +++ b/internal/apps/user/errs.go @@ -5,44 +5,44 @@ package user const ( - errBindParamsFailed = "参数绑定失败" - errInvalidParams = "无效的参数" - errPasswordLoginDisabled = "管理员关闭了密码登录" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errUsernameOrPasswordWrong = "用户名或密码错误" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errLoginEmailMissing = "该账号未绑定邮箱,请联系管理员绑定邮箱后再登录" + errBindParamsFailed = "Failed to bind parameters" + errInvalidParams = "invalid parameters" + errPasswordLoginDisabled = "Password login is disabled by the administrator" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errUsernameOrPasswordWrong = "Incorrect username or password" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errLoginEmailMissing = "This account has no email address; contact an administrator to bind an email before signing in" errNeedEmailCodePrefix = "need_email_code:" errSMTPInvalidUseTempCodePrefix = "smtp_invalid:" - errSMTPInvalidUseTempCode = "smtp 配置无效,使用临时码登录" - errEmailCodeInvalidOrExpired = "验证码错误或已过期" - errSaveSessionFailed = "无法保存会话信息,请重试" - errRegistrationDisabled = "管理员关闭了注册" - errPasswordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errEmailOrCodeRequired = "邮箱或验证码未填写" - errNewPasswordTooShort = "新密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errLoginRequired = "请先登录" - errUserNotFound = "未找到该用户" - errOldPasswordIncorrect = "原密码不正确" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errPasswordEncryptFailed = "密码加密失败,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errEmailRequired = "邮箱地址不能为空" - errUnsupportedEmailScene = "不支持的验证场景" - errEmailAlreadyRegistered = "该邮箱已被注册" - errEmailCodeCooldown = "验证码发送频繁,请稍后再试" - errLoginRateLimited = "登录尝试过于频繁,请稍后再试" - errEmailFormatInvalid = "邮箱格式不正确" - errEmailAlreadyBound = "该邮箱已被其他账号绑定" - errRenderEmailTemplateFailed = "渲染验证邮件模板失败:%w" - errGenerateEmailCodeFailed = "生成验证码失败,请重试" - errDispatchEmailTaskFailed = "投递验证邮件发送任务失败,请重试" - errTokenNameRequired = "令牌名称不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errAccessTokenLimitReached = "已达到访问令牌最大创建数量限制" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errGenerateTokenFailed = "生成令牌失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errInvalidTokenID = "无效的令牌ID" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errTokenNotFoundOrForbidden = "令牌不存在或无权操作" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errAdminTokenRequiresAdmin = "只有管理员才能创建具有管理员权限的令牌" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errTaskPayloadRequired = "任务参数不能为空" - errInvalidJSONFormat = "无效的 JSON 格式: %w" - errEmailTaskFieldsRequired = "to、subject、body 不能为空" - errParseEmailPayloadFailed = "解析邮件发送参数失败: %w" - errSMTPConfigIncomplete = "系统 SMTP 邮件服务配置不完整" - errSendMailFailed = "发送邮件失败: %w" + errSMTPInvalidUseTempCode = "Invalid SMTP configuration; use a temporary code to sign in" + errEmailCodeInvalidOrExpired = "verification code is incorrect or expired" + errSaveSessionFailed = "Unable to save session; please try again" + errRegistrationDisabled = "Registration is disabled by the administrator" + errPasswordTooShort = "password must contain at least 8 characters" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errEmailOrCodeRequired = "email address or verification code is missing" + errNewPasswordTooShort = "New password must contain at least 8 characters" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errLoginRequired = "Please sign in first" + errUserNotFound = "user was not found" + errOldPasswordIncorrect = "current password is incorrect" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errPasswordEncryptFailed = "password encryption failed; please try again" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errEmailRequired = "email address must not be empty" + errUnsupportedEmailScene = "Unsupported verification scenario" + errEmailAlreadyRegistered = "email address is already registered" + errEmailCodeCooldown = "verification codes are being requested too frequently; try again later" + errLoginRateLimited = "Too many login attempts; try again later" + errEmailFormatInvalid = "invalid email address format" + errEmailAlreadyBound = "this email address is bound to another account" + errRenderEmailTemplateFailed = "failed to render verification email template: %w" + errGenerateEmailCodeFailed = "failed to generate verification code; please try again" + errDispatchEmailTaskFailed = "failed to enqueue verification email task; please try again" + errTokenNameRequired = "Token name must not be empty" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errAccessTokenLimitReached = "Maximum number of access tokens reached" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errGenerateTokenFailed = "failed to generate token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errInvalidTokenID = "Invalid token ID" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errTokenNotFoundOrForbidden = "token was not found or access is not permitted" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errAdminTokenRequiresAdmin = "Only administrators can create tokens with administrator privileges" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errTaskPayloadRequired = "task parameters must not be empty" + errInvalidJSONFormat = "invalid JSON format: %w" + errEmailTaskFieldsRequired = "to, subject and body must not be empty" + errParseEmailPayloadFailed = "failed to parse email parameters: %w" + errSMTPConfigIncomplete = "system SMTP configuration is incomplete" + errSendMailFailed = "failed to send email: %w" ) diff --git a/internal/apps/user/logics.go b/internal/apps/user/logics.go index 5f354aaf..12b7d14d 100644 --- a/internal/apps/user/logics.go +++ b/internal/apps/user/logics.go @@ -174,7 +174,7 @@ func sendEmailVerificationCode(ctx context.Context, email, scene, templateName s tmpl, err := repository.GetTemplateByKey(ctx, templateName) if err != nil { - return fmt.Errorf("模板 %s 不存在或不可用: %w", templateName, err) + return fmt.Errorf("template %s was not found or is unavailable: %w", templateName, err) } emailSubject, emailBody, err := tmpl.Render(map[string]any{"Code": code}) if err != nil { @@ -235,7 +235,7 @@ func processLoginEmailVerification(ctx context.Context, code string, user *model if !isSMTPConfigured(ctx) { msg = errSMTPInvalidUseTempCodePrefix + errSMTPInvalidUseTempCode } else { - msg = errSMTPInvalidUseTempCodePrefix + "该账号未绑定邮箱,使用临时码登录" + msg = errSMTPInvalidUseTempCodePrefix + "This account has no email address; use a temporary code to sign in" } return LoginEmailVerificationResult{ Status: LoginEmailVerificationRejected, @@ -348,9 +348,9 @@ func updateLastLogin(ctx context.Context, user *model.User) error { func registerUserLogic(ctx context.Context, u *model.User) error { if err := repository.RegisterUserWithChecks(ctx, u); err != nil { if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "UNIQUE") { - return errors.New("用户名或邮箱已被占用") + return errors.New("username or email address is already in use") } - return errors.New("注册失败,请稍后再试") + return errors.New("registration failed; please try again later") } return nil } @@ -370,7 +370,7 @@ func changePasswordLogic(ctx context.Context, userID uint64, oldPass, newPass st } if err := repository.UpdateUserPassword(ctx, dbUser.ID, dbUser.Password); err != nil { - return errors.New("更新密码失败,请稍后再试") + return errors.New("failed to update password; please try again later") } // 吊销该用户所有的 Access Token @@ -380,7 +380,7 @@ func changePasswordLogic(ctx context.Context, userID uint64, oldPass, newPass st } } if err := repository.DeleteAccessTokensByUserID(ctx, dbUser.ID); err != nil { - return errors.New("吊销 Access Token 失败,请稍后再试") + return errors.New("failed to revoke access token; please try again later") } oauth.InvalidateCachedUser(ctx, dbUser.ID) @@ -390,7 +390,7 @@ func changePasswordLogic(ctx context.Context, userID uint64, oldPass, newPass st func listAccessTokensLogic(ctx context.Context, userID uint64) ([]model.AccessToken, error) { tokens, err := repository.ListAccessTokensByUserID(ctx, userID) if err != nil { - return nil, errors.New("获取令牌列表失败,请稍后再试") + return nil, errors.New("failed to get token list; please try again later") } return tokens, nil } @@ -398,14 +398,14 @@ func listAccessTokensLogic(ctx context.Context, userID uint64) ([]model.AccessTo func countAccessTokensLogic(ctx context.Context, userID uint64) (int64, error) { count, err := repository.CountAccessTokensByUserID(ctx, userID) if err != nil { - return 0, errors.New("查询令牌数量失败,请稍后再试") + return 0, errors.New("failed to count tokens; please try again later") } return count, nil } func createAccessTokenLogic(ctx context.Context, record *model.AccessToken) error { if err := repository.CreateAccessToken(ctx, record); err != nil { - return errors.New("创建令牌失败,请稍后再试") + return errors.New("failed to create token; please try again later") } oauth.SetCachedToken(ctx, record.TokenHash, record) return nil @@ -420,7 +420,7 @@ func deleteAccessTokenLogic(ctx context.Context, id, userID uint64) error { rows, err := repository.DeleteAccessTokenForUser(ctx, id, userID) if err != nil { - return errors.New("删除令牌失败,请稍后再试") + return errors.New("failed to delete token; please try again later") } if rows == 0 { return errors.New(errTokenNotFoundOrForbidden) @@ -448,7 +448,7 @@ func rotateAccessTokenLogic(ctx context.Context, id, userID uint64) (string, *mo tokenRecord.MaskedToken = newMaskedToken if err := repository.SaveAccessToken(ctx, &tokenRecord); err != nil { - return "", nil, errors.New("轮换令牌失败,请稍后再试") + return "", nil, errors.New("failed to rotate token; please try again later") } oauth.SetCachedToken(ctx, tokenRecord.TokenHash, &tokenRecord) diff --git a/internal/apps/user/logics_test.go b/internal/apps/user/logics_test.go index f9a284c1..e6e16b23 100644 --- a/internal/apps/user/logics_test.go +++ b/internal/apps/user/logics_test.go @@ -114,7 +114,7 @@ func TestProcessLoginEmailVerificationEmptyEmailFallback(t *testing.T) { if err != nil { t.Fatalf("processLoginEmailVerification() error = %v, want nil", err) } - expected := errSMTPInvalidUseTempCodePrefix + "该账号未绑定邮箱,使用临时码登录" + expected := errSMTPInvalidUseTempCodePrefix + "This account has no email address; use a temporary code to sign in" if result.Status != LoginEmailVerificationRejected || result.Message != expected { t.Fatalf("processLoginEmailVerification() = %+v, want rejected with %q", result, expected) } diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go index 445176ba..4d1079b9 100644 --- a/internal/apps/user/routers.go +++ b/internal/apps/user/routers.go @@ -123,7 +123,7 @@ func Login(c *gin.Context) { user.LastLoginAt = time.Now() if err := updateLastLogin(ctx, user); err != nil { - response.AbortBadRequest(c, "更新登录时间失败,请稍后再试") + response.AbortBadRequest(c, "Failed to update last login time; please try again later") return } extras := map[string]any{} @@ -313,7 +313,7 @@ func ChangePassword(c *gin.Context) { session.Clear() _ = session.Save() - c.JSON(http.StatusOK, response.OK("密码修改成功")) + c.JSON(http.StatusOK, response.OK("Password changed successfully")) } // SendEmailCode 发送邮箱验证码 diff --git a/internal/apps/user/routers_test.go b/internal/apps/user/routers_test.go index 4e6385ba..fedc4db6 100644 --- a/internal/apps/user/routers_test.go +++ b/internal/apps/user/routers_test.go @@ -418,7 +418,7 @@ func TestLoginEmailVerificationFallbackForEmptyEmail(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response failed: %v", err) } - expectedError := errSMTPInvalidUseTempCodePrefix + "该账号未绑定邮箱,使用临时码登录" + expectedError := errSMTPInvalidUseTempCodePrefix + "This account has no email address; use a temporary code to sign in" if resp.ErrorMsg != expectedError { t.Errorf("expected error %q, got %q", expectedError, resp.ErrorMsg) } diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 0849f326..fe580462 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -29,8 +29,8 @@ const ( var SendEmailMeta = task.TaskMeta{ Type: TaskTypeSendEmail, AsynqTask: SendEmailTask, - Name: "发送邮件", - Description: "异步发送系统邮件", + Name: "Send email", + Description: "Send system email asynchronously", SupportsTime: false, MaxRetry: task.DefaultMaxRetry, Queue: task.QueueDefault, @@ -38,27 +38,27 @@ var SendEmailMeta = task.TaskMeta{ Params: []task.TaskParam{ { Name: "to", - Label: "接收邮箱 (To)", + Label: "Recipient email (To)", Type: "string", Required: true, Placeholder: "receiver@example.com", - Description: "接收邮件的目标邮箱地址", + Description: "Destination email address", }, { Name: "subject", - Label: "邮件主题 (Subject)", + Label: "Email subject", Type: "string", Required: true, - Placeholder: "请输入邮件主题", - Description: "发送邮件的主题标题", + Placeholder: "Enter the email subject", + Description: "Subject line of the email to send", }, { Name: "body", - Label: "邮件内容 (Body)", + Label: "Email body", Type: "text", Required: true, - Placeholder: "请输入邮件内容(支持 HTML 格式)", - Description: "发送邮件的内容主体", + Placeholder: "Enter the email body (HTML is supported)", + Description: "Content of the email to send", }, }, } @@ -100,11 +100,11 @@ func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) { func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { var req SendEmailPayload if err := json.Unmarshal(payload, &req); err != nil { - task.AppendLog(ctx, "解析邮件发送参数失败: %v", err) + task.AppendLog(ctx, "Failed to parse email parameters: %v", err) return nil, fmt.Errorf(errParseEmailPayloadFailed, err) } - task.AppendLog(ctx, "开始准备发送邮件到: %s, 主题: %s", req.To, req.Subject) + task.AppendLog(ctx, "Preparing to send email to: %s, subject: %s", req.To, req.Subject) // 从数据库读取最新的 SMTP 系统配置 var smtpHost string @@ -127,7 +127,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T if smtpHost == "" || smtpPortVal == "" || smtpUsername == "" { err := errors.New(errSMTPConfigIncomplete) - task.AppendLog(ctx, "发送失败: %v", err) + task.AppendLog(ctx, "Send failed: %v", err) return nil, err } @@ -143,16 +143,16 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T Password: smtpPassword, } - task.AppendLog(ctx, "连接 SMTP 服务器: %s:%d, 用户名: %s", smtpHost, smtpPort, smtpUsername) + task.AppendLog(ctx, "Connecting to SMTP server: %s:%d, username: %s", smtpHost, smtpPort, smtpUsername) // 调用 SendMailHTML 执行邮件发送,这里会有 5s 拨号超时和 10s 读写限制 err = mail.SendMailHTML(ctx, cfg, req.To, req.Subject, req.Body) if err != nil { - task.AppendLog(ctx, "邮件发送失败: %v", err) + task.AppendLog(ctx, "Failed to send email: %v", err) return nil, fmt.Errorf(errSendMailFailed, err) } - msg := "邮件成功发送至: " + req.To + msg := "Email sent successfully to: " + req.To task.AppendLog(ctx, "%s", msg) return &task.TaskResult{ diff --git a/internal/cmd/all.go b/internal/cmd/all.go index 9da6e689..608159f4 100644 --- a/internal/cmd/all.go +++ b/internal/cmd/all.go @@ -18,9 +18,9 @@ import ( var allCmd = &cobra.Command{ Use: "all", - Short: "以融合模式同时启动 API、Worker 和 Scheduler", + Short: "Start API, Worker and Scheduler together", Run: func(_ *cobra.Command, _ []string) { - log.Println("[All] 融合模式启动") + log.Println("[All] Starting API, Worker and Scheduler") bootstrap.RegisterAll() runBootstrap(bootstrap.Options{API: true}) @@ -28,7 +28,7 @@ var allCmd = &cobra.Command{ // 启动 API HTTP 服务 wg.Go(func() { - log.Println("[All] 启动 API 服务") + log.Println("[All] Starting API service") router.Serve(func() { printStartupBanner(startupState{mode: "API + Worker + Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true}) }) @@ -36,17 +36,17 @@ var allCmd = &cobra.Command{ // 启动 Asynq Worker 任务处理服务 wg.Go(func() { - log.Println("[All] 启动 Worker 服务") + log.Println("[All] Starting Worker service") if err := worker.StartWorker(); err != nil { - log.Printf("[All] Worker 启动失败: %v\n", err) + log.Printf("[All] Failed to start Worker: %v\n", err) } }) // 启动 Asynq 定时任务调度器 wg.Go(func() { - log.Println("[All] 启动 Scheduler 服务") + log.Println("[All] Starting Scheduler service") if err := scheduler.StartScheduler(); err != nil { - log.Printf("[All] Scheduler 启动失败: %v\n", err) + log.Printf("[All] Failed to start Scheduler: %v\n", err) } }) diff --git a/internal/cmd/reset_passwd.go b/internal/cmd/reset_passwd.go index 2b8c39ed..5938ca3e 100644 --- a/internal/cmd/reset_passwd.go +++ b/internal/cmd/reset_passwd.go @@ -46,7 +46,7 @@ func generateRandomPassword(length int) (string, error) { var resetPasswdCmd = &cobra.Command{ Use: "reset-passwd", - Short: "重置指定账号密码", + Short: "Reset the password for a specified account", PreRun: func(_ *cobra.Command, _ []string) { migrator.Migrate() }, @@ -58,24 +58,24 @@ var resetPasswdCmd = &cobra.Command{ if usernameFlag != "" { username = usernameFlag } else { - fmt.Print("请输入用户名: ") + fmt.Print("Enter username: ") reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') if err != nil { - log.Fatalf("读取用户名失败: %v\n", err) + log.Fatalf("Failed to read username: %v\n", err) } username = strings.TrimSpace(input) if username == "" { - log.Fatal("用户名不能为空\n") + log.Fatal("Username must not be empty\n") } } user, err := repository.GetUserByUsername(ctx, username) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - log.Fatalf("错误: 用户 '%s' 不存在\n", username) + log.Fatalf("Error: user '%s' was not found\n", username) } - log.Fatalf("查询用户失败: %v\n", err) + log.Fatalf("Failed to query user: %v\n", err) } var password string @@ -84,12 +84,12 @@ var resetPasswdCmd = &cobra.Command{ } else { password, err = generateRandomPassword(defaultPasswordLength) if err != nil { - log.Fatalf("生成随机密码失败: %v\n", err) + log.Fatalf("Failed to generate random password: %v\n", err) } } if err := user.SetEncryptedPassword(password); err != nil { - log.Fatalf("加密密码失败: %v\n", err) + log.Fatalf("Failed to encrypt password: %v\n", err) } err = db.DB(ctx).Transaction(func(tx *gorm.DB) error { @@ -108,19 +108,19 @@ var resetPasswdCmd = &cobra.Command{ return tx.Where("user_id = ?", user.ID).Delete(&model.AccessToken{}).Error }) if err != nil { - log.Fatalf("重置密码失败: %v\n", err) + log.Fatalf("Failed to reset password: %v\n", err) } oauth.InvalidateCachedUser(ctx, user.ID) - fmt.Println("成功重置密码!") - fmt.Printf("用户名: %s\n", user.Username) - fmt.Printf("新密码: %s\n", password) + fmt.Println("Password reset successfully") + fmt.Printf("Username: %s\n", user.Username) + fmt.Printf("New password: %s\n", password) }, } func init() { - resetPasswdCmd.Flags().StringVar(&usernameFlag, "user", "", "重置密码的目标用户名") - resetPasswdCmd.Flags().StringVar(&passwordFlag, "password", "", "新密码(若不指定,则自动生成随机密码)") + resetPasswdCmd.Flags().StringVar(&usernameFlag, "user", "", "Username of the account whose password will be reset") + resetPasswdCmd.Flags().StringVar(&passwordFlag, "password", "", "New password; generated randomly if omitted") rootCmd.AddCommand(resetPasswdCmd) } diff --git a/internal/cmd/reset_passwd_test.go b/internal/cmd/reset_passwd_test.go index 2acae0e8..5530250b 100644 --- a/internal/cmd/reset_passwd_test.go +++ b/internal/cmd/reset_passwd_test.go @@ -69,7 +69,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { _, _ = io.Copy(&buf, r) output := buf.String() - if !bytes.Contains([]byte(output), []byte("成功重置密码!")) { + if !bytes.Contains([]byte(output), []byte("Password reset successfully")) { t.Errorf("expected output to contain success message, got: %s", output) } @@ -137,7 +137,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) { _, _ = io.Copy(&buf, r) output := buf.String() - if !bytes.Contains([]byte(output), []byte("成功重置密码!")) { + if !bytes.Contains([]byte(output), []byte("Password reset successfully")) { t.Errorf("expected output to contain success message, got: %s", output) } @@ -212,7 +212,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) { _, _ = io.Copy(&buf, r) output := buf.String() - if !bytes.Contains([]byte(output), []byte("成功重置密码!")) { + if !bytes.Contains([]byte(output), []byte("Password reset successfully")) { t.Errorf("expected output to contain success message, got: %s", output) } diff --git a/internal/cmd/scheduler.go b/internal/cmd/scheduler.go index 75f0a022..e13ea478 100644 --- a/internal/cmd/scheduler.go +++ b/internal/cmd/scheduler.go @@ -19,9 +19,9 @@ var schedulerCmd = &cobra.Command{ Run: func(_ *cobra.Command, _ []string) { runBootstrap(bootstrap.Options{}) printStartupBanner(startupState{mode: "Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB}) - log.Println("[Scheduler] 启动定时任务调度服务") + log.Println("[Scheduler] Starting scheduled task service") if err := scheduler.StartScheduler(); err != nil { - log.Fatalf("[调度器] 启动失败: %v", err) + log.Fatalf("[Scheduler] Failed to start: %v", err) } }, } diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go index 0c4d42be..156281f0 100644 --- a/internal/cmd/worker.go +++ b/internal/cmd/worker.go @@ -19,9 +19,9 @@ var workerCmd = &cobra.Command{ Run: func(_ *cobra.Command, _ []string) { runBootstrap(bootstrap.Options{}) printStartupBanner(startupState{mode: "Worker", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB}) - log.Println("[Worker] 启动任务处理服务") + log.Println("[Worker] Starting task processing service") if err := worker.StartWorker(); err != nil { - log.Fatalf("[工作器] 启动失败: %v", err) + log.Fatalf("[Worker] Failed to start: %v", err) } }, } diff --git a/internal/infra/objectstore/config.go b/internal/infra/objectstore/config.go index 0e90c4d3..a40368c9 100644 --- a/internal/infra/objectstore/config.go +++ b/internal/infra/objectstore/config.go @@ -178,7 +178,7 @@ func validateObjectConfig(cfg ObjectConfig, endpointRequired bool) error { // SaveActiveConfig persists the active storage configuration. func SaveActiveConfig(ctx context.Context, cfg Config) error { - return saveSystemConfig(ctx, model.ConfigKeyStorageConfig, cfg, "文件存储驱动及连接配置(JSON)") + return saveSystemConfig(ctx, model.ConfigKeyStorageConfig, cfg, "File storage backend and connection configuration (JSON)") } func saveSystemConfig(ctx context.Context, key string, value any, description string) error { diff --git a/internal/infra/task/errs.go b/internal/infra/task/errs.go index 6e020ef3..7494eead 100644 --- a/internal/infra/task/errs.go +++ b/internal/infra/task/errs.go @@ -3,15 +3,24 @@ package task +// RetryError identifies a rejected retry without depending on the message language. +type RetryError struct { + Err error + NotFound bool +} + +func (e *RetryError) Error() string { return e.Err.Error() } +func (e *RetryError) Unwrap() error { return e.Err } + const ( - errUnknownTaskType = "未知的任务类型: %s" - errCreateTaskExecutionFailed = "创建任务执行记录失败: %w" - errTaskEnqueueFailed = "任务入队失败: %w" - errTaskExecutionNotFound = "任务执行记录不存在: %w" - errRetryOnlyFailedTask = "只有失败的任务才能重试,当前状态: %s" - errTaskNotRetryable = "该任务不支持重试" - errTaskMaxRetryExceeded = "已达到最大重试次数 %d" - errCreateRetryExecutionFailed = "创建重试任务执行记录失败: %w" - errRetryTaskEnqueueFailed = "重试任务入队失败: %w" - errUnregisteredTaskHandler = "未注册的任务处理器: %s" + errUnknownTaskType = "unknown task type: %s" + errCreateTaskExecutionFailed = "failed to create task execution record: %w" + errTaskEnqueueFailed = "failed to enqueue task: %w" + errTaskExecutionNotFound = "task execution was not found: %w" + errRetryOnlyFailedTask = "only failed tasks can be retried; current status: %s" + errTaskNotRetryable = "this task cannot be retried" + errTaskMaxRetryExceeded = "Maximum retry count %d reached" + errCreateRetryExecutionFailed = "failed to create retry execution record: %w" + errRetryTaskEnqueueFailed = "failed to enqueue retry task: %w" + errUnregisteredTaskHandler = "unregistered task handler: %s" ) diff --git a/internal/infra/task/executor.go b/internal/infra/task/executor.go index 89ef029f..efc42e6b 100644 --- a/internal/infra/task/executor.go +++ b/internal/infra/task/executor.go @@ -113,7 +113,7 @@ func AppendLog(ctx context.Context, format string, args ...any) { logLine := fmt.Sprintf(format, args...) if err := repository.AppendTaskExecutionLog(ctx, taskID, logLine); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 追加任务日志失败 taskID=%s: %v", taskID, err) + logger.ErrorF(ctx, "[TaskExecutor] Failed to append task log taskID=%s: %v", taskID, err) } } @@ -154,7 +154,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere ); err != nil { // 入队失败,更新执行记录状态 execution.Status = model.TaskExecutionStatusFailed - execution.ErrorMessage = fmt.Sprintf("入队失败: %v", err) + execution.ErrorMessage = fmt.Sprintf("Failed to enqueue task: %v", err) now := time.Now() execution.StartedAt = &now execution.FinishedAt = &now @@ -162,8 +162,8 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere return "", fmt.Errorf(errTaskEnqueueFailed, err) } - if err := repository.AppendTaskExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)", meta.Queue, meta.MaxRetry)); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 追加入队日志失败 taskID=%s: %v", taskID, err) + if err := repository.AppendTaskExecutionLog(ctx, taskID, fmt.Sprintf("[System] Task enqueued; waiting for execution (queue: %s, maximum retries: %d)", meta.Queue, meta.MaxRetry)); err != nil { + logger.ErrorF(ctx, "[TaskExecutor] Failed to append enqueue log taskID=%s: %v", taskID, err) } return taskID, nil @@ -173,15 +173,15 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere func RetryTask(ctx context.Context, id uint64) (string, error) { execution, err := repository.GetTaskExecutionByID(ctx, id) if err != nil { - return "", fmt.Errorf(errTaskExecutionNotFound, err) + return "", &RetryError{Err: fmt.Errorf(errTaskExecutionNotFound, err), NotFound: true} } if execution.Status != model.TaskExecutionStatusFailed { - return "", fmt.Errorf(errRetryOnlyFailedTask, execution.Status) + return "", &RetryError{Err: fmt.Errorf(errRetryOnlyFailedTask, execution.Status)} } if !execution.Retryable { - return "", errors.New(errTaskNotRetryable) + return "", &RetryError{Err: errors.New(errTaskNotRetryable)} } // 生成新的 TaskID @@ -219,7 +219,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { asynq.Queue(queueName), ); err != nil { newExecution.Status = model.TaskExecutionStatusFailed - newExecution.ErrorMessage = fmt.Sprintf("重试入队失败: %v", err) + newExecution.ErrorMessage = fmt.Sprintf("Failed to enqueue retry: %v", err) now := time.Now() newExecution.StartedAt = &now newExecution.FinishedAt = &now @@ -227,8 +227,8 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { return "", fmt.Errorf(errRetryTaskEnqueueFailed, err) } - if err := repository.AppendTaskExecutionLog(ctx, newTaskID, fmt.Sprintf("[系统] 手动触发重试,已重新创建任务并入队 (原任务ID: %s, 重试次数: %d/%d)", execution.TaskID, execution.RetryCount+1, execution.MaxRetry)); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 追加重试日志失败 taskID=%s: %v", newTaskID, err) + if err := repository.AppendTaskExecutionLog(ctx, newTaskID, fmt.Sprintf("[System] Manual retry created and enqueued (original task ID: %s, retry: %d/%d)", execution.TaskID, execution.RetryCount+1, execution.MaxRetry)); err != nil { + logger.ErrorF(ctx, "[TaskExecutor] Failed to append retry log taskID=%s: %v", newTaskID, err) } return newTaskID, nil @@ -274,10 +274,10 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error { } if execution != nil { - AppendLog(ctx, "[系统] 开始执行异步任务 [名称: %s, 类型: %s],重试次数: %d/%d", + AppendLog(ctx, "[System] Starting asynchronous task [name: %s, type: %s], retry: %d/%d", execution.TaskName, t.Type(), execution.RetryCount, execution.MaxRetry) } else { - AppendLog(ctx, "[系统] 开始执行异步任务 [类型: %s]", t.Type()) + AppendLog(ctx, "[System] Starting asynchronous task [type: %s]", t.Type()) } // 开始计时 @@ -315,7 +315,7 @@ func injectTaskTraceContext(ctx context.Context, payload []byte) []byte { } data, err := json.Marshal(envelope) if err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 序列化任务 Trace 上下文失败: %v", err) + logger.ErrorF(ctx, "[TaskExecutor] Failed to serialize task trace context: %v", err) return payload } return data @@ -353,7 +353,7 @@ func updateExecutionOnStart(ctx context.Context, execution *model.TaskExecution, } if dirty { if updateErr := repository.UpdateTaskExecution(ctx, execution); updateErr != nil { - logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", execution.TaskID, updateErr) + logger.ErrorF(ctx, "[TaskExecutor] Failed to update execution status taskID=%s: %v", execution.TaskID, updateErr) } } } @@ -384,7 +384,7 @@ func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, } if createErr := repository.CreateTaskExecution(ctx, execution); createErr != nil { - logger.ErrorF(ctx, "[TaskExecutor] 动态创建执行记录失败 taskID=%s: %v", taskID, createErr) + logger.ErrorF(ctx, "[TaskExecutor] Failed to create execution record taskID=%s: %v", taskID, createErr) return nil, createErr } @@ -407,11 +407,11 @@ func completeTaskExecution(ctx context.Context, execution *model.TaskExecution, } if err := repository.UpdateTaskExecution(ctx, execution); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", execution.TaskID, err) + logger.ErrorF(ctx, "[TaskExecutor] Failed to update execution record taskID=%s: %v", execution.TaskID, err) } if shouldFlushTaskExecutionLog(ctx, execErr) { if err := repository.FlushTaskExecutionLog(ctx, execution.TaskID); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 持久化任务日志失败 taskID=%s: %v", execution.TaskID, err) + logger.ErrorF(ctx, "[TaskExecutor] Failed to persist task log taskID=%s: %v", execution.TaskID, err) } } @@ -449,11 +449,11 @@ func isTerminalTaskExecutionError(execErr error) bool { func handleFailedTask(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, execErr error, span trace.Span) { execution.Status = model.TaskExecutionStatusFailed execution.ErrorMessage = execErr.Error() - logger.ErrorF(ctx, "[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v", t.Type(), execution.TaskID, duration.Milliseconds(), execErr) + logger.ErrorF(ctx, "[TaskExecutor] Task processing failed Type: %s TaskID: %s Duration: %d ms Error: %v", t.Type(), execution.TaskID, duration.Milliseconds(), execErr) span.SetStatus(codes.Error, execErr.Error()) span.RecordError(execErr) - AppendLog(ctx, "[系统] 任务执行失败,耗时: %d ms,错误原因: %v", duration.Milliseconds(), execErr) + AppendLog(ctx, "[System] Task failed; duration: %d ms, error: %v", duration.Milliseconds(), execErr) } func handleSuccessfulTask(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, result *TaskResult) { @@ -465,13 +465,13 @@ func handleSuccessfulTask(ctx context.Context, execution *model.TaskExecution, t execution.Result = fmt.Sprintf("%s\n%s", result.Message, result.Detail) } } - logger.InfoF(ctx, "[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms", t.Type(), execution.TaskID, duration.Milliseconds()) + logger.InfoF(ctx, "[TaskExecutor] Task processing completed Type: %s TaskID: %s Duration: %d ms", t.Type(), execution.TaskID, duration.Milliseconds()) - resultMsg := "成功" + resultMsg := "Succeeded" if result != nil { resultMsg = result.Message } - AppendLog(ctx, "[系统] 任务执行成功,耗时: %d ms,执行结果: %s", duration.Milliseconds(), resultMsg) + AppendLog(ctx, "[System] Task succeeded; duration: %d ms, result: %s", duration.Milliseconds(), resultMsg) } // generateTaskID 生成任务 ID diff --git a/internal/infra/task/executor_test.go b/internal/infra/task/executor_test.go index 44efc23e..1f8855c0 100644 --- a/internal/infra/task/executor_test.go +++ b/internal/infra/task/executor_test.go @@ -286,7 +286,7 @@ func TestCompleteTaskExecutionFlushesLog(t *testing.T) { require.NoError(t, err) assert.Equal(t, model.TaskExecutionStatusSucceeded, found.Status) assert.Contains(t, found.Log, "任务执行中的日志") - assert.Contains(t, found.Log, "任务执行成功") + assert.Contains(t, found.Log, "Task succeeded") } func TestCompleteTaskExecutionFlushesPermanentFailureLog(t *testing.T) { @@ -327,7 +327,7 @@ func TestCompleteTaskExecutionFlushesPermanentFailureLog(t *testing.T) { assert.Equal(t, model.TaskExecutionStatusFailed, found.Status) assert.Equal(t, "来源配置无效", found.ErrorMessage) assert.Contains(t, found.Log, "永久失败前的日志") - assert.Contains(t, found.Log, "任务执行失败") + assert.Contains(t, found.Log, "Task failed") keys, err := db.Redis.Keys(ctx, "*"+execution.TaskID+"*").Result() require.NoError(t, err) assert.Empty(t, keys) @@ -405,7 +405,7 @@ func TestRetryTaskNotFailed(t *testing.T) { // 尝试重试成功的任务 _, err = RetryTask(ctx, execution.ID) require.Error(t, err) - assert.Contains(t, err.Error(), "只有失败的任务才能重试") + assert.Contains(t, err.Error(), "only failed tasks can be retried") } func TestRetryTaskNotRetryable(t *testing.T) { @@ -427,7 +427,7 @@ func TestRetryTaskNotRetryable(t *testing.T) { _, err = RetryTask(ctx, execution.ID) require.Error(t, err) - assert.Contains(t, err.Error(), "不支持重试") + assert.Contains(t, err.Error(), "cannot be retried") } func TestRetryTaskNonExistent(t *testing.T) { @@ -437,7 +437,7 @@ func TestRetryTaskNonExistent(t *testing.T) { _, err := RetryTask(ctx, 99999999) require.Error(t, err) - assert.Contains(t, err.Error(), "不存在") + assert.Contains(t, err.Error(), "not found") } func TestGenerateTaskID(t *testing.T) { diff --git a/internal/infra/task/permanent_error.go b/internal/infra/task/permanent_error.go index a0dad4f3..3e8ca370 100644 --- a/internal/infra/task/permanent_error.go +++ b/internal/infra/task/permanent_error.go @@ -9,7 +9,7 @@ import ( "github.com/hibiken/asynq" ) -const defaultPermanentErrorMessage = "任务无法继续执行" +const defaultPermanentErrorMessage = "Task cannot continue" type permanentTaskError struct { message string diff --git a/internal/infra/task/scheduler/scheduler.go b/internal/infra/task/scheduler/scheduler.go index 5c8e17c1..8e41cb3e 100644 --- a/internal/infra/task/scheduler/scheduler.go +++ b/internal/infra/task/scheduler/scheduler.go @@ -120,7 +120,7 @@ func ReloadScheduler() error { asynq.Queue(meta.Queue), ); err != nil { // 定时任务配置可能有误(如 Cron 格式不被 Asynq 识别),记录日志并跳过 - logger.ErrorF(context.Background(), "[Scheduler] 注册定时任务失败 id=%d name=%s: %v", s.ID, s.Name, err) + logger.ErrorF(context.Background(), "[Scheduler] Failed to register scheduled task id=%d name=%s: %v", s.ID, s.Name, err) continue } } @@ -131,7 +131,7 @@ func ReloadScheduler() error { } activeScheduler = newScheduler - logger.InfoF(context.Background(), "[Scheduler] 成功重新加载定时任务,共注册 %d 个活动任务", len(schedules)) + logger.InfoF(context.Background(), "[Scheduler] Reloaded schedules and registered %d active tasks", len(schedules)) return nil } diff --git a/internal/integration/githubrelease/client.go b/internal/integration/githubrelease/client.go index 0195002d..28fec726 100644 --- a/internal/integration/githubrelease/client.go +++ b/internal/integration/githubrelease/client.go @@ -50,14 +50,14 @@ const ( ) var ( - errInvalidRequest = errors.New("GitHub Release 请求参数无效") - errMetadata = errors.New("GitHub Release 元数据响应无效") - errAssetMissing = errors.New("GitHub Release 中未找到指定的已上传 asset") - errDownload = errors.New("GitHub Release asset 下载失败") - errTooLarge = errors.New("GitHub Release asset 超过大小限制") - errEmptyAsset = errors.New("GitHub Release asset 内容为空") - errDigest = errors.New("GitHub Release asset digest 无效或校验失败") - errCleanup = errors.New("GitHub Release 临时文件清理失败") + errInvalidRequest = errors.New("invalid GitHub Release request parameters") + errMetadata = errors.New("invalid GitHub Release metadata response") + errAssetMissing = errors.New("the specified uploaded asset was not found in the GitHub Release") + errDownload = errors.New("failed to download GitHub Release asset") + errTooLarge = errors.New("GitHub Release asset exceeds the size limit") + errEmptyAsset = errors.New("GitHub Release asset is empty") + errDigest = errors.New("GitHub Release asset digest is invalid or verification failed") + errCleanup = errors.New("failed to clean up GitHub Release temporary file") ownerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`) repoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) @@ -167,9 +167,9 @@ type Error struct { func (providerError *Error) Error() string { if providerError == nil { - return "GitHub Release 请求失败" + return "GitHub Release request failed" } - message := "GitHub Release 请求失败" + message := "GitHub Release request failed" if providerError.Kind != nil { message = providerError.Kind.Error() } diff --git a/internal/integration/githubrelease/transport.go b/internal/integration/githubrelease/transport.go index 7ea75e4e..e825d0ab 100644 --- a/internal/integration/githubrelease/transport.go +++ b/internal/integration/githubrelease/transport.go @@ -30,9 +30,9 @@ const ( ) var ( - errBlockedTarget = errors.New("GitHub Release 请求目标不是公网地址") - errResolveTarget = errors.New("GitHub Release 请求目标解析失败") - errRedirectLimit = errors.New("GitHub Release asset 重定向次数过多") + errBlockedTarget = errors.New("GitHub Release request target is not a public address") + errResolveTarget = errors.New("failed to resolve GitHub Release request target") + errRedirectLimit = errors.New("GitHub Release asset redirect limit exceeded") publicIPv6Prefix = netip.MustParsePrefix("2000::/3") nonPublicPrefixes = []netip.Prefix{ diff --git a/internal/model/errs.go b/internal/model/errs.go index 51da0c48..0f13d40c 100644 --- a/internal/model/errs.go +++ b/internal/model/errs.go @@ -6,12 +6,12 @@ package model // Domain validation messages used by model.Validate and other no-IO rules. // Persistence / data-access messages belong in internal/repository (do not import repository). const ( - errTemplateKeyRequired = "模板标识符不能为空" - errTemplateNameRequired = "模板名称不能为空" - errTemplateContentRequired = "模板内容不能为空" - errAuthSourceNameRequired = "认证源名称不能为空" - errAuthSourceNameInvalid = "认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头" - errAuthSourceTypeUnsupported = "认证源类型仅支持 oidc" - errAuthSourceDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL" - errAuthSourceClientCredentialsRequired = "启用认证源前必须配置 Client ID 和 Client Secret" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errTemplateKeyRequired = "Template identifier must not be empty" + errTemplateNameRequired = "Template name must not be empty" + errTemplateContentRequired = "Template content must not be empty" + errAuthSourceNameRequired = "authentication source name must not be empty" + errAuthSourceNameInvalid = "authentication source name may only contain letters, digits, hyphens and underscores, and must start with a letter or digit" + errAuthSourceTypeUnsupported = "authentication source type must be oidc" + errAuthSourceDiscoveryURLRequired = "OIDC authentication sources require a Discovery URL" + errAuthSourceClientCredentialsRequired = "client ID and Client Secret are required before enabling an authentication source" //nolint:gosec // false positive: this is an error message, not hardcoded credentials ) diff --git a/internal/model/task_execution.go b/internal/model/task_execution.go index 2ecb4a80..f1ae1b8a 100644 --- a/internal/model/task_execution.go +++ b/internal/model/task_execution.go @@ -34,7 +34,7 @@ type TaskExecution struct { Result string `json:"result" gorm:"type:text"` StartedAt *time.Time `json:"started_at" gorm:"index"` FinishedAt *time.Time `json:"finished_at"` - Duration int64 `json:"duration" gorm:"comment:耗时毫秒"` + Duration int64 `json:"duration" gorm:"comment:Duration in milliseconds"` Payload string `json:"payload" gorm:"type:text"` TriggeredBy string `json:"triggered_by" gorm:"size:32;not null;default:system"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` diff --git a/internal/platform/bootstrap/bootstrap.go b/internal/platform/bootstrap/bootstrap.go index 259f5b75..b17360cd 100644 --- a/internal/platform/bootstrap/bootstrap.go +++ b/internal/platform/bootstrap/bootstrap.go @@ -136,8 +136,8 @@ func RegisterAll() { func Init(ctx context.Context, opts Options) { initRuntimeOnce.Do(func() { if err := validateAndSeedLogDatabase(ctx); err != nil { - logger.ErrorF(ctx, "[Bootstrap] 日志主库配置校验失败: %v", err) - log.Fatalf("[Bootstrap] 日志主库配置校验失败: %v", err) + logger.ErrorF(ctx, "[Bootstrap] Log database configuration validation failed: %v", err) + log.Fatalf("[Bootstrap] Log database configuration validation failed: %v", err) } // 注入 logstore 配置读取(避免 logstore ↔ repository 循环依赖),并预热激活 store。 @@ -176,7 +176,7 @@ func Init(ctx context.Context, opts Options) { func validateAndSeedLogDatabase(ctx context.Context) error { cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("读取日志主库配置失败: %w", err) + return fmt.Errorf("failed to read log database configuration: %w", err) } current := cfg.Value if current == "" { @@ -190,25 +190,25 @@ func validateAndSeedLogDatabase(ctx context.Context) error { } // 行缺失时 UpdateSystemConfigFields 仅为 UPDATE 无法插入,改用可创建可更新的 SaveOrUpdateSystemConfig。 if err := repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDatabase, current); err != nil { - return fmt.Errorf("初始化日志主库配置失败: %w", err) + return fmt.Errorf("failed to initialize log database configuration: %w", err) } return nil } switch current { case "clickhouse": if !config.Config.ClickHouse.Enabled { - return errors.New("当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse") + return errors.New("the log database is ClickHouse, but ClickHouse is disabled. Re-enable ClickHouse and start the server, then run the Switch log database task to migrate to PostgreSQL/SQLite before disabling ClickHouse") } case "postgres": if !config.Config.Database.Enabled { - return errors.New("当前日志主库为 PostgreSQL 但 PostgreSQL 未启用(当前为 SQLite 主库)。请运行『切换日志数据库』迁回 SQLite 或启用 PostgreSQL") + return errors.New("the log database is PostgreSQL, but PostgreSQL is disabled and SQLite is the primary database. Run the Switch log database task to migrate to SQLite, or enable PostgreSQL") } case "sqlite": if config.Config.Database.Enabled { - return errors.New("当前日志主库为 SQLite 但当前主库为 PostgreSQL。请运行『切换日志数据库』迁移到 PostgreSQL") + return errors.New("the log database is SQLite, but PostgreSQL is the primary database. Run the Switch log database task to migrate to PostgreSQL") } default: - return fmt.Errorf("未知的日志主库配置: %s", current) + return fmt.Errorf("unknown log database configuration: %s", current) } return nil } diff --git a/internal/platform/bootstrap/bootstrap_test.go b/internal/platform/bootstrap/bootstrap_test.go index 05c1eb62..f97b49a5 100644 --- a/internal/platform/bootstrap/bootstrap_test.go +++ b/internal/platform/bootstrap/bootstrap_test.go @@ -51,8 +51,8 @@ func TestInitSyncsPushEventsOnce(t *testing.T) { if err := dbConn.Where("event_key = ?", "admin_login").First(&adminLogin).Error; err != nil { t.Fatalf("admin_login event not found after Init: %v", err) } - if adminLogin.Name != "管理员登录" { - t.Fatalf("admin_login name = %q, want %q", adminLogin.Name, "管理员登录") + if adminLogin.Name != "Administrator login" { + t.Fatalf("admin_login name = %q, want %q", adminLogin.Name, "Administrator login") } } @@ -170,10 +170,10 @@ func TestValidateAndSeedLogDatabaseRejectsInconsistentConfig(t *testing.T) { chEnabled bool wantErr string }{ - {name: "clickhouse marker but disabled", marker: "clickhouse", dbEnabled: true, chEnabled: false, wantErr: "ClickHouse 未启用"}, - {name: "postgres marker but disabled", marker: "postgres", dbEnabled: false, chEnabled: false, wantErr: "PostgreSQL 未启用"}, + {name: "clickhouse marker but disabled", marker: "clickhouse", dbEnabled: true, chEnabled: false, wantErr: "ClickHouse is disabled"}, + {name: "postgres marker but disabled", marker: "postgres", dbEnabled: false, chEnabled: false, wantErr: "PostgreSQL is disabled"}, {name: "sqlite marker but postgres primary", marker: "sqlite", dbEnabled: true, chEnabled: false, wantErr: "SQLite"}, - {name: "unknown marker", marker: "mysql", dbEnabled: false, chEnabled: false, wantErr: "未知的日志主库配置"}, + {name: "unknown marker", marker: "mysql", dbEnabled: false, chEnabled: false, wantErr: "unknown log database configuration"}, {name: "consistent sqlite", marker: "sqlite", dbEnabled: false, chEnabled: false, wantErr: ""}, } for _, tt := range tests { diff --git a/internal/repository/auth_source.go b/internal/repository/auth_source.go index f45cb146..5eda2708 100644 --- a/internal/repository/auth_source.go +++ b/internal/repository/auth_source.go @@ -179,7 +179,7 @@ func ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]model.E if account.AuthSourceID == 0 { name = "default" sourceType = "oidc" - label = "历史认证源" + label = "Historical authentication source" } else { source, err := GetAuthSourceByID(ctx, account.AuthSourceID) if err != nil { diff --git a/internal/repository/errs.go b/internal/repository/errs.go index 92f5e2b4..52cb6d80 100644 --- a/internal/repository/errs.go +++ b/internal/repository/errs.go @@ -10,16 +10,16 @@ package repository // but do not import or re-export model unexported consts (would require exporting). const ( errDatabaseNotInitialized = "database not initialized" - errConfigIntParseFailed = "配置 %s 的值 '%s' 无法转换为整数: %w" - errConfigDecimalParseFailed = "配置 %s 的值 '%s' 无法转换为decimal: %w" - errConfigBoolParseFailed = "配置 %s 的值 '%s' 无法转换为布尔值: %w" - errParseMenuDisplayConfigFailed = "解析目录显示配置失败: %w" - errAuthSourceNameRequired = "认证源名称不能为空" - errAuthSourceIDRequired = "认证源 ID 不能为空" - errExternalAccountBindingIncomplete = "外部账号绑定信息不完整" - errExternalAccountAlreadyBoundToAnother = "该外部账号已绑定到其他用户" - errUserIDRequired = "用户 ID 不能为空" - errExternalAccountBindingIDRequired = "绑定记录 ID 不能为空" + errConfigIntParseFailed = "cannot convert configuration %s value '%s' to an integer: %w" + errConfigDecimalParseFailed = "cannot convert configuration %s value '%s' to a decimal: %w" + errConfigBoolParseFailed = "cannot convert configuration %s value '%s' to a boolean: %w" + errParseMenuDisplayConfigFailed = "failed to parse directory display configuration: %w" + errAuthSourceNameRequired = "authentication source name must not be empty" + errAuthSourceIDRequired = "authentication source ID must not be empty" + errExternalAccountBindingIncomplete = "external account binding information is incomplete" + errExternalAccountAlreadyBoundToAnother = "this external account is bound to another user" + errUserIDRequired = "user ID must not be empty" + errExternalAccountBindingIDRequired = "account binding ID must not be empty" ) const colName = "name" diff --git a/internal/repository/logstore/cleanup.go b/internal/repository/logstore/cleanup.go index 50c34e5d..e370827d 100644 --- a/internal/repository/logstore/cleanup.go +++ b/internal/repository/logstore/cleanup.go @@ -53,13 +53,13 @@ func retentionDaysForDatabase(ctx context.Context, dbName string) int { v, err := getConfig(ctx, key) if err != nil { if !errors.Is(err, errConfigReaderNotWired) { - logger.ErrorF(ctx, "读取日志保留天数配置失败(key=%s),回退默认 %d 天: %v", key, defaultLogRetentionDays, err) + logger.ErrorF(ctx, "Failed to read log retention configuration (key=%s); using default of %d days: %v", key, defaultLogRetentionDays, err) } return defaultLogRetentionDays } days, perr := strconv.Atoi(v) if perr != nil || days <= 0 { - logger.ErrorF(ctx, "日志保留天数配置非法(key=%s, value=%q),回退默认 %d 天", key, v, defaultLogRetentionDays) + logger.ErrorF(ctx, "Invalid log retention configuration (key=%s, value=%q); using default of %d days", key, v, defaultLogRetentionDays) return defaultLogRetentionDays } return days @@ -70,13 +70,13 @@ func metricRetentionDays(ctx context.Context) int { v, err := getConfig(ctx, model.ConfigKeyMetricRetentionDays) if err != nil { if !errors.Is(err, errConfigReaderNotWired) { - logger.ErrorF(ctx, "读取性能指标保留天数配置失败(key=%s),回退默认 %d 天: %v", model.ConfigKeyMetricRetentionDays, defaultMetricRetentionDays, err) + logger.ErrorF(ctx, "Failed to read metric retention configuration (key=%s); using default of %d days: %v", model.ConfigKeyMetricRetentionDays, defaultMetricRetentionDays, err) } return defaultMetricRetentionDays } days, perr := strconv.Atoi(v) if perr != nil || days <= 0 { - logger.ErrorF(ctx, "性能指标保留天数配置非法(key=%s, value=%q),回退默认 %d 天", model.ConfigKeyMetricRetentionDays, v, defaultMetricRetentionDays) + logger.ErrorF(ctx, "Invalid metric retention configuration (key=%s, value=%q); using default of %d days", model.ConfigKeyMetricRetentionDays, v, defaultMetricRetentionDays) return defaultMetricRetentionDays } return days diff --git a/internal/repository/user.go b/internal/repository/user.go index ddd840d6..57c0eed8 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -42,7 +42,7 @@ func GetSystemUser(ctx context.Context) model.User { return model.User{ ID: 999, Username: configTypeSystem, - Nickname: "系统", + Nickname: "System", } } @@ -274,7 +274,7 @@ func RegisterUserWithChecks(ctx context.Context, user *model.User) error { return err } if count > 0 { - return errors.New("用户名已存在") + return errors.New("username already exists") } if user.Email != "" { var emailCount int64 @@ -282,7 +282,7 @@ func RegisterUserWithChecks(ctx context.Context, user *model.User) error { return err } if emailCount > 0 { - return errors.New("该邮箱已被其他账号绑定") + return errors.New("this email address is bound to another account") } } if user.ID == 0 { diff --git a/internal/shared/errs.go b/internal/shared/errs.go index c5c61b8c..c0eac6cb 100644 --- a/internal/shared/errs.go +++ b/internal/shared/errs.go @@ -6,6 +6,6 @@ package shared // 通用业务错误消息常量 const ( - BannedAccount = "账号已被封禁" - UnAuthorized = "未登录" + BannedAccount = "account is disabled" + UnAuthorized = "Not signed in" ) diff --git a/internal/shared/response/middleware.go b/internal/shared/response/middleware.go index 7b893d72..7281c1e9 100644 --- a/internal/shared/response/middleware.go +++ b/internal/shared/response/middleware.go @@ -7,6 +7,8 @@ import ( "errors" "net/http" + "github.com/Rain-kl/Wavelet/pkg/i18n" + "github.com/gin-gonic/gin" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" @@ -31,10 +33,16 @@ func ErrorHandlerMiddleware() gin.HandlerFunc { var apiErr *APIError if errors.As(err, &apiErr) { - c.JSON(apiErr.Code, Err(apiErr.Msg)) + c.JSON(apiErr.Code, Err(i18n.Translate(Locale(c), apiErr.Msg))) return } - c.JSON(http.StatusInternalServerError, Err("内部系统错误")) + c.JSON(http.StatusInternalServerError, Err(i18n.Translate(Locale(c), "Internal system error"))) } } + +// Locale uses the same cookie as the frontend, with Accept-Language for API clients. +func Locale(c *gin.Context) string { + cookie, _ := c.Cookie("NEXT_LOCALE") + return i18n.ResolveLocale(cookie, c.GetHeader("Accept-Language")) +} diff --git a/internal/shared/response/middleware_test.go b/internal/shared/response/middleware_test.go index d593f7cc..8e715d7c 100644 --- a/internal/shared/response/middleware_test.go +++ b/internal/shared/response/middleware_test.go @@ -133,7 +133,7 @@ func TestErrorHandlerMiddleware_FallbackForNonAPIError(t *testing.T) { var body Response[any] require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, "内部系统错误", body.ErrorMsg) + assert.Equal(t, "Internal system error", body.ErrorMsg) assert.Nil(t, body.Data) } @@ -150,19 +150,23 @@ func TestErrorHandlerMiddleware_RecordsSpanOnAPIError(t *testing.T) { r.Use(ErrorHandlerMiddleware()) r.GET("/err", func(c *gin.Context) { c.Request = c.Request.WithContext(ctx) - AbortBadRequest(c, "bad request") + AbortBadRequest(c, "invalid target log database: custom-中文") }) req := httptest.NewRequest(http.MethodGet, "/err", nil) + req.AddCookie(&http.Cookie{Name: "NEXT_LOCALE", Value: "zh-CN"}) w := httptest.NewRecorder() r.ServeHTTP(w, req) span.End() require.Equal(t, http.StatusBadRequest, w.Code) + var body Any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "目标日志库不合法: custom-中文", body.ErrorMsg) spans := sr.Ended() require.Len(t, spans, 1) assert.Equal(t, codes.Error, spans[0].Status().Code) - assert.Equal(t, "bad request", spans[0].Status().Description) + assert.Equal(t, "invalid target log database: custom-中文", spans[0].Status().Description) require.NotEmpty(t, spans[0].Events()) } diff --git a/pkg/i18n/catalog.go b/pkg/i18n/catalog.go new file mode 100644 index 00000000..de5a808b --- /dev/null +++ b/pkg/i18n/catalog.go @@ -0,0 +1,286 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package i18n translates built-in server text at presentation boundaries. +// Runtime diagnostics and stored task text remain English. +package i18n + +import ( + "embed" + "encoding/json" + "io/fs" + "path" + "regexp" + "sort" + "strconv" + "strings" + "unicode" +) + +//go:embed locales/*.json +var catalogFiles embed.FS + +// DefaultLocale is the source language used for API clients without a preference. +const DefaultLocale = "en" + +type textPattern struct { + id string + match *regexp.Regexp + prefix string + suffix string + score int +} + +type catalog struct { + locales map[string]map[string]string + exact map[string]string + patterns []textPattern +} + +var ( + printfVerb = regexp.MustCompile(`%(?:\[[0-9]+\])?[-+# 0]*(?:[0-9]+|\*)?(?:\.(?:[0-9]+|\*))?[a-zA-Z%]`) + builtIn = loadCatalog() +) + +func loadCatalog() catalog { + result := catalog{locales: make(map[string]map[string]string), exact: make(map[string]string)} + files, err := fs.Glob(catalogFiles, "locales/*.json") + if err != nil { + panic(err) + } + for _, file := range files { + data, readErr := catalogFiles.ReadFile(file) + if readErr != nil { + panic(readErr) + } + var messages map[string]string + if decodeErr := json.Unmarshal(data, &messages); decodeErr != nil { + panic(decodeErr) + } + locale := strings.TrimSuffix(path.Base(file), ".json") + result.locales[locale] = messages + } + locales := make([]string, 0, len(result.locales)) + for locale := range result.locales { + locales = append(locales, locale) + } + sort.Strings(locales) + for _, locale := range locales { + messages := result.locales[locale] + ids := make([]string, 0, len(messages)) + for id := range messages { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + text := messages[id] + if _, exists := result.exact[text]; !exists { + result.exact[text] = id + } + if pattern, ok := compilePattern(id, text); ok { + result.patterns = append(result.patterns, pattern) + } + } + } + // Go errors use a lowercase initial. Recognize the earlier capitalized + // English spelling too when displaying existing task errors and logs. + english := result.locales[DefaultLocale] + ids := make([]string, 0, len(english)) + for id := range english { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + text := english[id] + if text == "" || text[0] < 'a' || text[0] > 'z' { + continue + } + legacy := strings.ToUpper(text[:1]) + text[1:] + if _, exists := result.exact[legacy]; exists { + continue + } + result.exact[legacy] = id + if pattern, ok := compilePattern(id, legacy); ok { + result.patterns = append(result.patterns, pattern) + } + } + sort.SliceStable(result.patterns, func(left, right int) bool { + return result.patterns[left].score > result.patterns[right].score + }) + return result +} + +func splitFormat(format string) ([]string, bool) { + indices := printfVerb.FindAllStringIndex(format, -1) + if len(indices) == 0 { + // Some existing boundaries append an external error to a literal prefix. + if strings.HasSuffix(format, ": ") { + return []string{format, ""}, true + } + return nil, false + } + parts := []string{} + start := 0 + for _, index := range indices { + if format[index[0]:index[1]] == "%%" { + continue + } + parts = append(parts, strings.ReplaceAll(format[start:index[0]], "%%", "%")) + start = index[1] + } + if len(parts) == 0 { + return nil, false + } + parts = append(parts, strings.ReplaceAll(format[start:], "%%", "%")) + return parts, true +} + +func compilePattern(id, format string) (textPattern, bool) { + parts, ok := splitFormat(format) + if !ok { + return textPattern{}, false + } + score := 0 + var pattern strings.Builder + pattern.WriteString("(?s)^") + for index, part := range parts { + score += len(part) + if index > 0 { + pattern.WriteString("(.*?)") + } + pattern.WriteString(regexp.QuoteMeta(part)) + } + pattern.WriteString("$") + if score == 0 { + return textPattern{}, false + } + return textPattern{id: id, match: regexp.MustCompile(pattern.String()), prefix: parts[0], suffix: parts[len(parts)-1], score: score}, true +} + +// ResolveLocale honors a supported locale cookie, then Accept-Language, then English. +func ResolveLocale(cookie, acceptLanguage string) string { + if locale, ok := supportedLocale(cookie); ok { + return locale + } + type preference struct { + locale string + weight float64 + } + preferences := make([]preference, 0) + for _, item := range strings.Split(acceptLanguage, ",") { + fields := strings.Split(item, ";") + locale, ok := supportedLocale(fields[0]) + if !ok { + continue + } + weight := 1.0 + for _, field := range fields[1:] { + if value, found := strings.CutPrefix(strings.TrimSpace(field), "q="); found { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || parsed < 0 || parsed > 1 { + weight = 0 + } else { + weight = parsed + } + } + } + if weight > 0 { + preferences = append(preferences, preference{locale: locale, weight: weight}) + } + } + sort.SliceStable(preferences, func(left, right int) bool { + return preferences[left].weight > preferences[right].weight + }) + if len(preferences) > 0 { + return preferences[0].locale + } + return DefaultLocale +} + +func supportedLocale(value string) (string, bool) { + value = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "_", "-")) + for locale := range builtIn.locales { + if strings.EqualFold(locale, value) { + return locale, true + } + } + base, _, _ := strings.Cut(value, "-") + if base == "zh" { + _, ok := builtIn.locales["zh-CN"] + return "zh-CN", ok + } + _, ok := builtIn.locales[base] + return base, ok +} + +// Translate renders known system text without modifying interpolated values. +// Both English and historical translated templates are recognized. Unknown text +// is returned unchanged; this function must only be used for system-owned fields. +func Translate(locale, text string) string { + // Authentication responses include stable control prefixes consumed by the + // login UI. Translate their human-readable suffix, preserving the protocol. + for _, prefix := range []string{"smtp_invalid:", "need_email_code:"} { + if suffix, ok := strings.CutPrefix(text, prefix); ok { + return prefix + translate(locale, suffix) + } + } + return translate(locale, text) +} + +func translate(locale, text string) string { + messages, ok := builtIn.locales[locale] + if !ok { + messages = builtIn.locales[DefaultLocale] + } + if id, found := builtIn.exact[text]; found { + if translated, exists := messages[id]; exists { + return translated + } + return text + } + // English diagnostics are already in the source language. Avoid scanning the + // legacy catalog for ordinary English log lines. + if locale == DefaultLocale && !strings.ContainsFunc(text, func(r rune) bool { return unicode.Is(unicode.Han, r) }) { + return text + } + for _, pattern := range builtIn.patterns { + if !strings.HasPrefix(text, pattern.prefix) || !strings.HasSuffix(text, pattern.suffix) { + continue + } + matches := pattern.match.FindStringSubmatch(text) + if len(matches) == 0 { + continue + } + translated, found := messages[pattern.id] + if !found { + return text + } + parts, valid := splitFormat(translated) + if !valid || len(parts) != len(matches) { + return text + } + var output strings.Builder + for index, part := range parts { + if index > 0 { + output.WriteString(matches[index]) + } + output.WriteString(part) + } + return output.String() + } + return text +} + +// TranslateLog preserves timestamps and unknown legacy lines while translating +// the known system template on each line. Stored log contents are never rewritten. +func TranslateLog(locale, text string) string { + lines := strings.Split(text, "\n") + for index, line := range lines { + prefix := "" + if len(line) >= len("[00:00:00] ") && line[0] == '[' && line[9] == ']' && line[10] == ' ' { + prefix, line = line[:11], line[11:] + } + lines[index] = prefix + Translate(locale, line) + } + return strings.Join(lines, "\n") +} diff --git a/pkg/i18n/catalog_test.go b/pkg/i18n/catalog_test.go new file mode 100644 index 00000000..85d35055 --- /dev/null +++ b/pkg/i18n/catalog_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package i18n + +import ( + "fmt" + "reflect" + "regexp" + "sort" + "strings" + "testing" +) + +func TestResolveLocale(t *testing.T) { + for _, tc := range []struct{ cookie, header, want string }{ + {"en", "zh-CN", "en"}, + {"zh-CN", "en", "zh-CN"}, + {"invalid", "en;q=0.4,zh-CN;q=0.9", "zh-CN"}, + {"", "ru-RU,en-US;q=0.8", "ru"}, + {"ru", "en", "ru"}, + {"ru_RU", "en", "ru"}, + {"", "ru;q=0,en;q=0.5", "en"}, + {"", "zh-TW", "zh-CN"}, + {"", "zh;q=0,en;q=0.5", "en"}, + {"", "zh;q=invalid", "en"}, + {"zh-invalid-prefix", "", "zh-CN"}, + {"zhbad", "", "en"}, + {"", "", "en"}, + } { + t.Run(tc.cookie+"/"+tc.header, func(t *testing.T) { + if got := ResolveLocale(tc.cookie, tc.header); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestTranslatePreservesArgumentsAndUnknownText(t *testing.T) { + for _, tc := range []struct{ locale, source, want string }{ + {"zh-CN", "Scanning certificates due for renewal", "开始扫描待续期证书"}, + {"ru", "Administrator login", "Вход администратора"}, + {"ru", "invalid target log database: custom-value", "Недопустимая целевая база журналов: custom-value"}, + {"en", "Вход администратора", "Administrator login"}, + {"ru", "Unknown custom message", "Unknown custom message"}, + {"en", "开始扫描待续期证书", "Scanning certificates due for renewal"}, + {"en", "目标日志库不合法: 用户数据", "invalid target log database: 用户数据"}, + {"zh-CN", "invalid target log database: 用户数据", "目标日志库不合法: 用户数据"}, + {"en", "自定义名称,不是系统消息", "自定义名称,不是系统消息"}, + {"zh-CN", "An unknown third-party error", "An unknown third-party error"}, + {"unknown", "开始扫描待续期证书", "Scanning certificates due for renewal"}, + {"en", "smtp_invalid:该账号未绑定邮箱,使用临时码登录", "smtp_invalid:This account has no email address; use a temporary code to sign in"}, + {"zh-CN", "smtp_invalid:This account has no email address; use a temporary code to sign in", "smtp_invalid:该账号未绑定邮箱,使用临时码登录"}, + {"zh-CN", "need_email_code:60", "need_email_code:60"}, + } { + t.Run(tc.locale+"/"+tc.source, func(t *testing.T) { + if got := Translate(tc.locale, tc.source); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } + legacy := "[12:34:56] 开始扫描待续期证书\n[12:34:57] 自定义日志\n" + want := "[12:34:56] Scanning certificates due for renewal\n[12:34:57] 自定义日志\n" + if got := TranslateLog("en", legacy); got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestTranslateRecognizesHistoricalEnglishErrorCase(t *testing.T) { + for _, source := range []string{ + "invalid target log database: custom-value", + "Invalid target log database: custom-value", + } { + if got, want := Translate("zh-CN", source), "目标日志库不合法: custom-value"; got != want { + t.Errorf("Translate(%q, %q) = %q, want %q", "zh-CN", source, got, want) + } + } +} + +func TestCatalogParityAndFormats(t *testing.T) { + variables := regexp.MustCompile(`\{\{[^{}]+\}\}`) + payloadVariables := regexp.MustCompile(`\$[\pL_][\pL\pN_]*(?:\.[\pL_][\pL\pN_]*)*`) + english := builtIn.locales[DefaultLocale] + for locale, messages := range builtIn.locales { + if len(messages) != len(english) { + t.Fatalf("%s has %d messages, want %d", locale, len(messages), len(english)) + } + for id, source := range english { + translated, ok := messages[id] + if !ok || translated == "" { + t.Errorf("%s is missing %s", locale, id) + } + if !reflect.DeepEqual(printfVerb.FindAllString(source, -1), printfVerb.FindAllString(translated, -1)) { + t.Errorf("%s %s changes formatting verbs", locale, id) + } + sourceVariables := variables.FindAllString(source, -1) + translatedVariables := variables.FindAllString(translated, -1) + sort.Strings(sourceVariables) + sort.Strings(translatedVariables) + if !reflect.DeepEqual(sourceVariables, translatedVariables) { + t.Errorf("%s %s changes template variables", locale, id) + } + sourcePayload := payloadVariables.FindAllString(source, -1) + translatedPayload := payloadVariables.FindAllString(translated, -1) + sort.Strings(sourcePayload) + sort.Strings(translatedPayload) + if !reflect.DeepEqual(sourcePayload, translatedPayload) { + t.Errorf("%s %s changes payload variables", locale, id) + } + } + } +} + +func TestTranslateLegacyTaskLog(t *testing.T) { + source := "[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)" + got := Translate("en", fmt.Sprintf(source, "custom-中文", 3)) + want := "[System] Task enqueued; waiting for execution (queue: custom-中文, maximum retries: 3)" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestNotificationPublicErrorsHaveTranslations(t *testing.T) { + for _, source := range []string{ + "this notification event is already configured", + "cannot enable event without any push channels configured", + "custom template is not a valid JSON format", + "unsupported task type", + "channel name already exists", + "channel name is required", + "channel name can only contain letters, numbers, and underscores", + "request URL/address is required", + "request URL must use HTTPS protocol for security reasons", + "payload schema (request body) is required", + "payload schema must be a valid JSON format", + "telegram bot token is required", + } { + for _, locale := range []string{"ru", "zh-CN"} { + if got := Translate(locale, source); got == source || got == "" { + t.Errorf("Translate(%q, %q) = %q, want a localized public error", locale, source, got) + } + } + } +} + +func TestValidationMessagesPreserveAcceptedSyntax(t *testing.T) { + contracts := map[string][]string{ + "s_must_be_a_duration_with_a_unit_b97fceb4": {"30m", "5s"}, + "match_mode_for_node_s_must_be_and_65944b17": {"and", "or"}, + "pow_algorithm_for_node_s_must_be_fast_97989167": {"fast", "slow"}, + "s_must_be_true_or_false_3a8153ff": {"true", "false"}, + "geolocation_provider_must_be_disabled_mmdb_ip_api_08bc0f62": {"disabled", "mmdb", "ip-api", "geojs", "ipinfo"}, + "s_must_be_disabled_mmdb_ip_api_geojs_deba53da": {"disabled", "mmdb", "ip-api", "geojs", "ipinfo"}, + "image_quality_must_be_low_medium_or_high_3d699272": {"low", "medium", "high"}, + "webp_compression_quality_must_be_low_medium_or_866b5127": {"low", "medium", "high"}, + } + for locale, messages := range builtIn.locales { + for key, tokens := range contracts { + for _, token := range tokens { + if !strings.Contains(messages[key], token) { + t.Errorf("%s %s message %q is missing accepted syntax %q", locale, key, messages[key], token) + } + } + } + } +} diff --git a/pkg/i18n/locales/en.json b/pkg/i18n/locales/en.json new file mode 100644 index 00000000..68976f4e --- /dev/null +++ b/pkg/i18n/locales/en.json @@ -0,0 +1,990 @@ +{ + "a_file_in_the_pages_deployment_archive_exceeds_bf65ffe2": "a file in the Pages deployment archive exceeds the size limit", + "a_new_github_release_deployment_archive_is_available_3ba1aeff": "A new GitHub Release deployment archive is available", + "a_node_ip_is_required_when_ip_locking_2fe806d7": "A node IP is required when IP locking is enabled", + "a_pages_deployment_source_task_is_already_running_2c10973f": "a Pages deployment source task is already running", + "a_preview_release_cannot_be_selected_for_a_b0318833": "a preview release cannot be selected for a stable update", + "account_binding_id_must_not_be_empty_84f24a4e": "account binding ID must not be empty", + "account_is_disabled_da6e1fd3": "account is disabled", + "activate_atomically_switching_github_release_deployment_80feec83": "[activate] Atomically switching GitHub Release deployment", + "activate_atomically_switching_production_deployment_80300199": "[activate] Atomically switching production deployment", + "activate_source_configuration_or_ownership_changed_deployment_was_e21f8288": "[activate] Source configuration or ownership changed; deployment was not switched", + "administrator_accounts_cannot_be_deleted_f3ae74a2": "administrator accounts cannot be deleted", + "administrator_accounts_cannot_be_disabled_d3eef776": "administrator accounts cannot be disabled", + "administrator_login_c454c61e": "Administrator login", + "administrator_login_notification_227c9f47": "Administrator login notification", + "administrator_privileges_cannot_be_revoked_from_the_currently_75858bcb": "administrator privileges cannot be revoked from the currently signed-in user", + "administrator_user_username_signed_in_at_time_from_a2edfba5": "Administrator {{user.username}} signed in at {{time}} from IP {{ip}}.", + "agent_token_and_discovery_token_must_not_both_ca76bf69": "agent_token and discovery_token must not both be empty", + "agent_token_is_empty_and_discovery_token_is_0349f2b6": "agent_token is empty and discovery_token is not configured", + "all_objects_have_been_migrated_93287840": "All objects have been migrated", + "already_running_the_latest_version_6e865701": "already running the latest version", + "an_asset_in_the_same_release_was_replaced_2878205a": "An asset in the same release was replaced; confirmation is required", + "an_asset_in_the_same_release_was_replaced_40e8d33a": "an asset in the same release was replaced; refresh and confirm the current version", + "an_update_is_already_in_progress_d5dae3bf": "an update is already in progress", + "another_storage_migration_task_is_already_running_937fbb49": "another storage migration task is already running", + "api_base_url_optional_e966f73e": "API base URL (optional)", + "api_reverse_proxy_backend_must_be_a_valid_4c68d5b8": "API reverse proxy backend must be a valid HTTP/HTTPS URL", + "api_reverse_proxy_match_path_must_start_with_662f1449": "API reverse proxy match path must start with '/'", + "archive_contains_an_invalid_path_s_0f89f375": "archive contains an invalid path: %s", + "archive_path_escapes_the_destination_s_c6d68c24": "archive path escapes the destination: %s", + "archives_8f10dc14": "Archives", + "asynchronous_task_task_name_id_task_id_completed_128e1c5d": "Asynchronous task {{task_name}} (ID: {{task_id}}) completed. Status: {{task_status}}; duration: {{task_duration}} ms.", + "audio_296c632e": "Audio", + "authentication_source_id_must_not_be_empty_87431a43": "authentication source ID must not be empty", + "authentication_source_is_disabled_7a948dfa": "Authentication source is disabled", + "authentication_source_must_not_be_empty_8c3909a8": "authentication source must not be empty", + "authentication_source_name_may_only_contain_letters_digits_71129406": "authentication source name may only contain letters, digits, hyphens and underscores, and must start with a letter or digit", + "authentication_source_name_must_not_be_empty_f85c91bb": "authentication source name must not be empty", + "authentication_source_type_must_be_oidc_3d9726ab": "authentication source type must be oidc", + "automatic_binary_replacement_is_not_supported_on_this_ef024e23": "automatic binary replacement is not supported on this platform", + "automatic_ip_group_configuration_must_be_a_json_535c4571": "automatic IP group configuration must be a JSON object", + "automatic_notification_triggered_when_an_asynchronous_task_finishes_b0349623": "Automatic notification triggered when an asynchronous task finishes", + "automatic_rules_completed_matched_d_ips_currently_active_a2f333b5": "Automatic rules completed; matched %d IPs, currently active %d IPs", + "automatic_ssl_renewal_failed_v_b3366eb5": "Automatic SSL renewal failed: %v", + "automatic_ssl_renewal_task_completed_b95fcc39": "Automatic SSL renewal task completed", + "automatic_updates_will_be_available_in_a_later_d19f8d56": "Automatic updates will be available in a later stage and must remain disabled for now", + "available_variables_title_description_content_url_to_example_5b046232": "Available variables: $title, $description, $content, $url, $to. Example: {\"text\": \"$content\"}", + "backend_url_is_required_when_the_api_reverse_8563c33e": "backend URL is required when the API reverse proxy is enabled", + "batch_completed_last_id_d_generated_d_hits_c7365ae6": "Batch completed, last ID: %d, generated: %d, hits: %d, failed: %d", + "block_response_body_for_node_s_must_not_8df3d52b": "block response body for node %s must not exceed %d bytes", + "block_status_code_for_node_s_must_be_3708ab21": "block status code for node %s must be between 400 and 599", + "bot_access_token_issued_by_botfather_ef54847f": "Bot access token issued by BotFather", + "bot_token_7ed368ad": "Bot token", + "both_latitude_and_longitude_are_required_451abfe8": "Both latitude and longitude are required", + "bucket_minutes_must_be_5_10_15_30_838c287d": "bucket_minutes must be 5, 10, 15, 30 or 60", + "bucket_started_at_must_be_an_rfc3339_timestamp_2d909e88": "bucket_started_at must be an RFC3339 timestamp", + "built_in_template_identifiers_cannot_be_changed_4e094f31": "Built-in template identifiers cannot be changed", + "built_in_templates_cannot_be_deleted_dd8af484": "built-in templates cannot be deleted", + "cannot_be_retried_93537915": "cannot be retried", + "cannot_convert_configuration_s_value_s_to_a_30f99a83": "cannot convert configuration %s value '%s' to a boolean: %w", + "cannot_convert_configuration_s_value_s_to_a_d601455b": "cannot convert configuration %s value '%s' to a decimal: %w", + "cannot_convert_configuration_s_value_s_to_an_bea99a42": "cannot convert configuration %s value '%s' to an integer: %w", + "captcha_verification_failed_missing_verification_credential_2b70bfd8": "CAPTCHA verification failed: missing verification credential", + "captcha_verification_failed_or_expired_please_try_again_83606d54": "CAPTCHA verification failed or expired; please try again", + "certificate_directory_must_not_be_empty_71b05ede": "certificate directory must not be empty", + "certificate_file_path_must_not_be_empty_9fb64a51": "certificate file path must not be empty", + "certificate_id_48409532": "Certificate ID", + "certificate_id_must_be_nonzero_742f9d12": "certificate ID must be nonzero", + "certificate_issuance_failed_v_c18caa92": "Certificate issuance failed: %v", + "certificate_s_id_d_is_not_acme_managed_0703919b": "Certificate %s (ID=%d) is not ACME-managed and cannot be renewed automatically (provider: %s)", + "certificate_s_is_not_acme_managed_and_cannot_e4d6469f": "certificate %s is not ACME-managed and cannot be renewed automatically", + "certificate_s_renewed_successfully_5ddb1448": "Certificate %s renewed successfully", + "channel_test_notification_2a7a7219": "Channel test notification", + "check_checking_github_release_repo_s_asset_s_9f927192": "[check] Checking GitHub Release: repo=%s asset=%s", + "check_existing_files_89d94c02": "Check existing files", + "check_github_provider_backoff_source_id_d_status_9b362b41": "[check] GitHub provider backoff: source_id=%d status=%d retry_at=%s", + "check_or_synchronize_a_pages_project_deployment_source_bdfc6949": "Check or synchronize a Pages project deployment source", + "check_source_is_running_another_task_skipping_source_ca7ae2fd": "[check] Source is running another task; skipping: source_id=%d", + "checking_cloudflare_group_primary_and_standby_node_status_e2df040e": "Checking Cloudflare group primary and standby node status", + "cleaned_up_d_task_execution_logs_d_high_2352273f": "Cleaned up %d task execution logs (%d high-frequency, %d low-frequency)", + "cleaning_up_expired_logs_using_log_database_retention_c4163ab0": "Cleaning up expired logs using log database retention and separate short metric retention...", + "cleaning_up_notification_audit_history_retaining_the_last_6074bdfe": "Cleaning up notification audit history; retaining the last 7 days...", + "cleaning_up_task_execution_logs_retaining_3_days_5f4a5eb2": "Cleaning up task execution logs: retaining 3 days for high-frequency tasks and 30 days for low-frequency tasks...", + "cleanup_compensating_unreferenced_deployment_archive_records_eb828b5c": "[cleanup] Compensating unreferenced deployment archive records", + "cleanup_completed_fce56fa2": "Cleanup completed", + "cleanup_orphan_candidates_d_compensated_d_referenced_d_21a3822b": "[cleanup] orphan candidates=%d, compensated=%d, referenced=%d, lease busy=%d, invalid markers=%d, skipped=%d, failed=%d", + "clickhouse_is_disabled_migration_to_clickhouse_is_unavailable_7bbf52f9": "ClickHouse is disabled; migration to ClickHouse is unavailable", + "client_id_and_client_secret_are_required_before_b1904682": "client ID and Client Secret are required before enabling an authentication source", + "cloudflare_api_request_failed_8fbea766": "Cloudflare API request failed", + "cloudflare_api_request_failed_w_1f530c09": "cloudflare API request failed: %w", + "cloudflare_api_retry_limit_exceeded_7e3673cc": "cloudflare API retry limit exceeded", + "cloudflare_api_token_is_inactive_4a0ca3bf": "cloudflare API token is inactive", + "cloudflare_dns_synchronization_failed_08371454": "Cloudflare DNS synchronization failed", + "cloudflare_domain_synchronization_8b6eca62": "Cloudflare domain synchronization", + "cloudflare_domain_synchronization_succeeded_b3b55a0a": "Cloudflare domain synchronization succeeded", + "cloudflare_domain_synchronization_succeeded_s_s_proxied_v_5b0eed54": "Cloudflare domain synchronization succeeded: %s -> %s (proxied=%v)", + "cloudflare_group_synchronization_d98bfd8f": "Cloudflare group synchronization", + "cloudflare_node_failover_check_57f894bc": "Cloudflare node failover check", + "cloudflare_node_failover_check_completed_switched_d_groups_1dd7476d": "Cloudflare node failover check completed; switched %d groups", + "cloudflare_node_failover_check_failed_error_v_52840d43": "Cloudflare node failover check failed: error=%v", + "cloudflare_node_synchronization_ed5311ec": "Cloudflare node synchronization", + "cloudflare_resource_was_not_found_8194db78": "Cloudflare resource was not found", + "cloudflare_s_synchronization_completed_s_d_domains_3d341ca3": "Cloudflare %s synchronization completed: %s, %d domains", + "cloudflare_s_synchronization_completed_s_has_no_domain_302881ff": "Cloudflare %s synchronization completed: %s has no domain members", + "cloudflare_s_synchronization_completed_s_s_d_domains_6aa7c0fd": "Cloudflare %s synchronization completed: %s -> %s, %d domains", + "compensate_orphaned_archives_recover_expired_leases_and_check_dd5bf0ee": "Clean up orphaned archives, recover expired source leases, and check GitHub sources tracking the latest release for updates", + "complete_https_endpoint_url_for_example_https_api_321a9514": "Complete HTTPS endpoint URL, for example https://api.example.com/webhook", + "complete_json_configuration_of_the_target_storage_backend_2a07e361": "Complete JSON configuration of the target storage backend", + "config_must_not_be_empty_a3a68460": "config must not be empty", + "configuration_contains_an_additional_json_value_b7e289fa": "configuration contains an additional JSON value", + "configuration_key_already_exists_6d757a67": "configuration key already exists", + "configuration_key_must_not_be_empty_e5cb19c0": "Configuration key must not be empty", + "configuration_must_not_be_null_32b5f98f": "configuration must not be null", + "configuration_path_is_not_initialized_6d59fe3e": "configuration path is not initialized", + "configuration_value_must_not_be_empty_20e379d9": "Configuration value must not be empty", + "configure_telegram_bot_notifications_9bffc349": "Configure Telegram bot notifications.", + "configured_cloudflare_api_token_is_invalid_0453f265": "configured Cloudflare API token is invalid", + "connecting_to_smtp_server_s_d_username_s_0186b04e": "Connecting to SMTP server: %s:%d, username: %s", + "content_of_the_email_to_send_77c2373e": "Content of the email to send", + "copied_d_access_logs_through_id_d_d6f86229": "Copied %d access logs (through id=%d)", + "copied_d_user_access_logs_through_id_d_f80f625f": "Copied %d user access logs (through id=%d)", + "copied_s_d_records_through_id_d_ee8dd5f7": "Copied %s: %d records (through id=%d)", + "copy_log_data_and_switch_the_log_database_0576d9c2": "Copy log data and switch the log database on success; log writes are paused during migration", + "copy_the_webhook_url_from_the_feishu_group_d0638f81": "Copy the webhook URL from the Feishu group bot settings", + "current_migration_batch_completed_progress_d_d_42eb3c40": "Current migration batch completed. Progress: %d/%d", + "current_password_is_incorrect_36d208b0": "current password is incorrect", + "current_storage_has_no_objects_to_migrate_active_533b6b0a": "Current storage has no objects to migrate; active storage switched to %s", + "custom_card_json_template_optional_be356c13": "Custom card JSON template (optional)", + "custom_notification_channel_f6ac176a": "Custom notification channel", + "custom_user_agent_pattern_in_node_s_must_1ae42a76": "custom User-Agent pattern in node %s must not be empty", + "custom_user_agent_pattern_in_node_s_must_3372446d": "custom User-Agent pattern in node %s must not exceed %d bytes", + "d_d_domain_record_no_longer_exists_removing_397ee9a8": "[%d/%d] Domain record no longer exists; removing orphaned member: member_id=%d zone_domain_id=%d", + "d_d_failed_domain_s_member_id_d_6e126949": "[%d/%d] Failed: domain=%s member_id=%d error=%v", + "d_d_failed_to_remove_orphaned_member_member_7b0a42ab": "[%d/%d] Failed to remove orphaned member: member_id=%d error=%v", + "d_d_succeeded_domain_s_2eb31693": "[%d/%d] Succeeded: domain=%s", + "d_d_synchronizing_domain_s_member_id_d_593b8a8a": "[%d/%d] Synchronizing domain %s (member_id=%d proxied=%v)", + "d_days_4a67fe38": "%d days", + "d_hours_e7b10e79": "%d hours", + "d_minutes_2bc2266f": "%d minutes", + "d_seconds_26064316": "%d seconds", + "database_is_not_initialized_0ab7ea2d": "Database is not initialized", + "database_update_updating_file_path_s_s_35e484ff": "[Database update] Updating file path: %s -> %s", + "days_5ab7e0f9": " days ", + "default_chat_id_optional_a982d25a": "Default chat ID (optional)", + "default_recipient_chat_id_used_when_the_notification_248a34c6": "Default recipient chat ID, used when the notification event has no targets", + "deleted_d_notification_history_records_cutoff_s_ca03970f": "Deleted %d notification history records (cutoff: %s)", + "deleted_successfully_5223f91b": "Deleted successfully", + "deliver_notifications_through_a_feishu_group_bot_webhook_c851f7b2": "Deliver notifications through a Feishu group bot webhook.", + "deployment_archive_at_the_url_exceeds_the_size_51b0e2ec": "Deployment archive at the URL exceeds the size limit", + "deployment_source_saved_but_the_initial_check_could_a4d74ed9": "Deployment source saved, but the initial check could not be enqueued; run a manual check later", + "destination_email_address_bb129242": "Destination email address", + "development_builds_do_not_support_automatic_updates_bb3a2946": "development builds do not support automatic updates", + "discovery_registration_response_is_missing_node_id_or_4823814c": "discovery registration response is missing node_id or agent_token", + "dispatch_automatic_update_dispatched_source_id_d_execution_72ffca53": "[dispatch] Automatic update dispatched: source_id=%d execution_id=%s revision=%s", + "dispatch_system_notifications_asynchronously_across_multiple_channels_0c5c685b": "Dispatch system notifications asynchronously across multiple channels", + "dns_account_parameters_are_incomplete_fbcb0fad": "DNS account parameters are incomplete", + "documents_2687ccdb": "Documents", + "domain_already_exists_a59a13de": "domain already exists", + "domain_does_not_belong_to_this_zone_bb1bc93f": "domain does not belong to this zone", + "domain_is_bound_to_a_proxy_route_remove_ad0a62b7": "domain is bound to a proxy route; remove the binding first", + "domain_name_must_not_be_empty_60a8eab8": "domain name must not be empty", + "domain_synchronization_failed_domain_s_member_id_d_31ec4ac5": "Domain synchronization failed: domain=%s member_id=%d error=%v", + "domain_synchronization_failed_member_id_d_error_v_36d3d285": "Domain synchronization failed: member_id=%d error=%v", + "domain_synchronization_succeeded_domain_s_desired_target_s_18cedff2": "Domain synchronization succeeded: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s", + "domain_synchronization_succeeded_member_id_d_2906410a": "Domain synchronization succeeded: member_id=%d", + "domain_was_not_found_e3b18d21": "Domain was not found", + "download_downloading_github_release_asset_repo_s_asset_f3760632": "[download] Downloading GitHub Release asset: repo=%s asset=%s", + "download_fetching_remote_deployment_archive_9c58137d": "[download] Fetching remote deployment archive", + "duplicate_edge_id_s_59427f0d": "duplicate edge ID %s", + "duplicate_node_id_s_6f7764f9": "duplicate node ID %s", + "edge_id_must_not_be_empty_049cfbf2": "edge ID must not be empty", + "edge_s_references_missing_source_node_s_b14a641b": "edge %s references missing source node %s", + "edge_s_references_missing_target_node_s_f38f63a4": "edge %s references missing target node %s", + "edge_s_source_port_s_is_not_valid_445c7518": "edge %s source port %s is not valid for node %s", + "email_address_is_already_registered_ca59e824": "email address is already registered", + "email_address_is_already_registered_f861225c": "email address is already registered", + "email_address_must_not_be_empty_28e55875": "email address must not be empty", + "email_address_must_not_be_empty_2e477cb1": "email address must not be empty", + "email_address_or_verification_code_is_missing_2b477d98": "email address or verification code is missing", + "email_body_db38bae9": "Email body", + "email_notification_channel_70b4e93c": "Email notification channel", + "email_notifications_use_the_global_smtp_settings_no_9b002fd1": "Email notifications use the global SMTP settings; no server configuration is required here.", + "email_sent_successfully_to_38e67ca0": "Email sent successfully to: ", + "email_subject_98a8f04e": "Email subject", + "enter_a_cloudflare_api_token_00262e30": "enter a Cloudflare API token", + "enter_a_valid_json_request_body_template_variables_34eba3ef": "Enter a valid JSON request body; template variables are supported", + "enter_the_certificate_id_f0f6ff01": "Enter the certificate ID", + "enter_the_cloudflare_target_group_id_830db5d3": "Enter the Cloudflare target group ID", + "enter_the_cloudflare_target_member_id_4a062db8": "Enter the Cloudflare target member ID", + "enter_the_complete_request_url_using_https_08ac14d8": "Enter the complete request URL using HTTPS", + "enter_the_deployment_archive_download_url_2fe91a17": "enter the deployment archive download URL", + "enter_the_email_body_html_is_supported_ca9985b5": "Enter the email body (HTML is supported)", + "enter_the_email_subject_852e1d02": "Enter the email subject", + "enter_the_secret_if_signature_verification_is_enabled_fd5f494e": "Enter the secret if signature verification is enabled for the bot", + "enter_the_telegram_bot_token_dcc672a5": "Enter the Telegram bot token", + "enter_username_e4435775": "Enter username: ", + "error_user_s_was_not_found_81b9b991": "Error: user '%s' was not found\n", + "event_key_2f70299f": "Event key", + "executor_is_not_configured_5d41479d": "Executor is not configured", + "existing_files_were_found_use_the_storage_migration_e099a954": "existing files were found; use the storage migration task to switch storage backends", + "expression_in_automatic_rule_d_must_not_be_f696189a": "expression in automatic rule %d must not be empty", + "external_account_binding_information_is_incomplete_180d928a": "external account binding information is incomplete", + "extracted_executable_exceeds_the_size_limit_9461c1e6": "extracted executable exceeds the size limit", + "extracted_pages_deployment_exceeds_the_size_limit_4cbef66f": "extracted Pages deployment exceeds the size limit", + "failed_to_back_up_current_executable_w_3addff1b": "failed to back up current executable: %w", + "failed_to_bind_parameters_5c410c6f": "Failed to bind parameters", + "failed_to_bind_parameters_provide_a_valid_array_b77c7672": "Failed to bind parameters; provide a valid array of file IDs", + "failed_to_check_existing_files_w_a851b86d": "failed to check existing files: %w", + "failed_to_clean_up_expired_logs_v_e8791024": "Failed to clean up expired logs: %v", + "failed_to_clean_up_github_release_temporary_file_00bf359d": "failed to clean up GitHub Release temporary file", + "failed_to_clean_up_remote_source_temporary_file_d721acd8": "Failed to clean up remote source temporary file", + "failed_to_clean_up_task_execution_logs_v_376be8e5": "Failed to clean up task execution logs: %v", + "failed_to_clean_up_upload_id_d_v_cf2710b1": "Failed to clean up upload [ID:%d]: %v", + "failed_to_clear_target_access_logs_w_9ff48e23": "failed to clear target access logs: %w", + "failed_to_clear_target_user_access_logs_w_d7c2ad8c": "failed to clear target user access logs: %w", + "failed_to_close_update_archive_w_0a42c60e": "failed to close update archive: %w", + "failed_to_compile_waf_rule_s_w_4392b9dc": "failed to compile WAF rule %s: %w", + "failed_to_copy_s_w_b0890ed6": "failed to copy %s: %w", + "failed_to_count_active_uploads_v_eccd0ce3": "Failed to count active uploads: %v", + "failed_to_count_notification_history_for_cleanup_v_87bcd35d": "Failed to count notification history for cleanup: %v", + "failed_to_count_tokens_please_try_again_later_d6be07bb": "failed to count tokens; please try again later", + "failed_to_create_retry_execution_record_w_250258f4": "failed to create retry execution record: %w", + "failed_to_create_task_execution_record_w_7bafae3b": "failed to create task execution record: %w", + "failed_to_create_token_please_try_again_later_268e7466": "failed to create token; please try again later", + "failed_to_create_update_archive_w_0e1ae531": "failed to create update archive: %w", + "failed_to_create_update_directory_w_f3414e06": "failed to create update directory: %w", + "failed_to_create_update_download_request_w_ca826654": "failed to create update download request: %w", + "failed_to_create_update_request_92de6fa7": "failed to create update request", + "failed_to_create_user_fe2017cc": "Failed to create user", + "failed_to_delete_cloudflare_dns_record_905df10a": "failed to delete Cloudflare DNS record", + "failed_to_delete_file_485d2b67": "Failed to delete file", + "failed_to_delete_notification_history_v_4308a791": "Failed to delete notification history: %v", + "failed_to_delete_schedule_82d7ea83": "Failed to delete schedule", + "failed_to_delete_token_please_try_again_later_8a65e358": "failed to delete token; please try again later", + "failed_to_delete_user_7cf04bd0": "Failed to delete user", + "failed_to_dispatch_task_40f01141": "Failed to dispatch task", + "failed_to_download_deployment_archive_from_url_ece95602": "Failed to download deployment archive from URL", + "failed_to_download_github_release_asset_445f42e6": "failed to download GitHub Release asset", + "failed_to_download_subscription_w_73516643": "failed to download subscription: %w", + "failed_to_download_update_asset_http_d_8bfeec63": "failed to download update asset: HTTP %d", + "failed_to_download_update_asset_w_15a3a529": "failed to download update asset: %w", + "failed_to_drain_log_write_queue_w_a0166ebc": "failed to drain log write queue: %w", + "failed_to_encrypt_password_v_92ab4495": "Failed to encrypt password: %v\n", + "failed_to_enqueue_pages_deployment_source_task_821d3915": "failed to enqueue Pages deployment source task", + "failed_to_enqueue_retry_task_w_2c39873e": "failed to enqueue retry task: %w", + "failed_to_enqueue_retry_v_5d869929": "Failed to enqueue retry: %v", + "failed_to_enqueue_task_v_a7c1aed2": "Failed to enqueue task: %v", + "failed_to_enqueue_task_w_9e65a379": "failed to enqueue task: %w", + "failed_to_enqueue_verification_email_task_please_try_ac66b52f": "failed to enqueue verification email task; please try again", + "failed_to_execute_automatic_rule_w_2271cde6": "failed to execute automatic rule: %w", + "failed_to_extract_update_asset_w_692981b3": "failed to extract update asset: %w", + "failed_to_fetch_upstream_version_e19bb5de": "Failed to fetch upstream version", + "failed_to_generate_random_password_v_f0fea5b7": "Failed to generate random password: %v\n", + "failed_to_generate_token_c78757e3": "failed to generate token", + "failed_to_generate_verification_code_please_try_again_d83752a4": "failed to generate verification code; please try again", + "failed_to_get_certificate_record_id_d_v_6d59f24e": "Failed to get certificate record ID=%d: %v", + "failed_to_get_certificate_record_w_214148cd": "failed to get certificate record: %w", + "failed_to_get_latest_version_w_fca8b7de": "failed to get latest version: %w", + "failed_to_get_log_database_status_da58b757": "Failed to get log database status", + "failed_to_get_preview_version_w_d61ed476": "failed to get preview version: %w", + "failed_to_get_specified_version_w_63e83abe": "failed to get specified version: %w", + "failed_to_get_token_list_please_try_again_96e34095": "failed to get token list; please try again later", + "failed_to_get_user_list_27ad5285": "Failed to get user list", + "failed_to_initialize_log_database_configuration_w_adfaefb2": "failed to initialize log database configuration: %w", + "failed_to_initialize_log_storage_20fdb546": "Failed to initialize log storage", + "failed_to_initialize_system_options_783dd4ca": "Failed to initialize system options", + "failed_to_initialize_test_storage_instance_w_e45afe79": "failed to initialize test storage instance: %w", + "failed_to_list_s_members_name_s_id_fa26680a": "Failed to list %s members: name=%s id=%d error=%v", + "failed_to_load_active_node_group_s_active_4064ae7a": "Failed to load active node: group=%s active_node_id=%d error=%v", + "failed_to_load_group_group_id_d_error_af85458e": "Failed to load group: group_id=%d error=%v", + "failed_to_load_member_context_member_id_d_5dba6ac0": "Failed to load member context: member_id=%d error=%v", + "failed_to_load_node_node_id_d_error_e404b970": "Failed to load node: node_id=%d error=%v", + "failed_to_locate_current_executable_w_8e100f0b": "failed to locate current executable: %w", + "failed_to_migrate_zone_d_conflicts_w_fd8934cb": "failed to migrate zone (%d conflicts): %w", + "failed_to_open_file_756f4b8e": "Failed to open file", + "failed_to_parse_current_storage_configuration_w_6eb179f7": "failed to parse current storage configuration: %w", + "failed_to_parse_directory_display_configuration_w_b45c310f": "failed to parse directory display configuration: %w", + "failed_to_parse_email_parameters_v_2d5f065f": "Failed to parse email parameters: %v", + "failed_to_parse_email_parameters_w_0af9b984": "failed to parse email parameters: %w", + "failed_to_parse_image_cache_warmup_parameters_w_80cc27e9": "failed to parse image cache warmup parameters: %w", + "failed_to_parse_json_subscription_w_7171f213": "failed to parse JSON subscription: %w", + "failed_to_parse_notification_parameters_v_7c7c0d1a": "Failed to parse notification parameters: %v", + "failed_to_parse_parameters_w_d098976c": "failed to parse parameters: %w", + "failed_to_parse_preview_version_information_8c63f444": "failed to parse preview version information", + "failed_to_parse_target_storage_configuration_w_dedc816a": "failed to parse target storage configuration: %w", + "failed_to_parse_version_information_9f1ec272": "failed to parse version information", + "failed_to_precreate_target_postgresql_partitions_w_97bd4df9": "failed to precreate target PostgreSQL partitions: %w", + "failed_to_process_file_9a1e91ff": "Failed to process file", + "failed_to_process_image_id_d_v_f89e5f9a": "Failed to process image [ID:%d]: %v", + "failed_to_query_access_trends_740f8684": "Failed to query access trends: ", + "failed_to_query_active_users_d78284d9": "Failed to query active users: ", + "failed_to_query_browser_distribution_483ef559": "Failed to query browser distribution: ", + "failed_to_query_file_count_b2b5322f": "Failed to query file count", + "failed_to_query_file_list_a50606c2": "Failed to query file list", + "failed_to_query_file_record_f2d055b2": "Failed to query file record", + "failed_to_query_image_upload_records_v_72a8a849": "Failed to query image upload records: %v", + "failed_to_query_images_for_cache_warmup_w_daee6ed8": "failed to query images for cache warmup: %w", + "failed_to_query_unused_uploads_v_4b22bf86": "Failed to query unused uploads: %v", + "failed_to_query_unused_uploads_w_915f2d8f": "failed to query unused uploads: %w", + "failed_to_query_user_information_w_612e6300": "failed to query user information: %w", + "failed_to_query_user_v_31dc6f02": "Failed to query user: %v\n", + "failed_to_read_aggregate_statistics_v_1fafe12a": "Failed to read aggregate statistics: %v", + "failed_to_read_log_database_configuration_w_8d46eff5": "failed to read log database configuration: %w", + "failed_to_read_log_database_v_f6136632": "Failed to read log database: %v", + "failed_to_read_log_database_w_0502e3ef": "failed to read log database: %w", + "failed_to_read_source_access_log_time_range_d9667403": "failed to read source access log time range: %w", + "failed_to_read_source_user_access_log_time_691f3bb2": "failed to read source user access log time range: %w", + "failed_to_read_subscription_content_w_e3665864": "failed to read subscription content: %w", + "failed_to_read_username_v_f5ab6d40": "Failed to read username: %v\n", + "failed_to_rebuild_file_storage_statistics_v_3a12ad55": "Failed to rebuild file storage statistics: %v", + "failed_to_remove_previous_backup_w_9f9ce6cb": "failed to remove previous backup: %w", + "failed_to_render_verification_email_template_w_6191557f": "failed to render verification email template: %w", + "failed_to_replace_current_executable_w_a286fcde": "failed to replace current executable: %w", + "failed_to_reset_password_v_628117ce": "Failed to reset password: %v\n", + "failed_to_resolve_current_executable_path_w_fd2316e8": "failed to resolve current executable path: %w", + "failed_to_resolve_github_release_request_target_4f4e6fdc": "failed to resolve GitHub Release request target", + "failed_to_retrieve_file_records_fd17cc6c": "Failed to retrieve file records", + "failed_to_retry_task_147b8518": "Failed to retry task", + "failed_to_revoke_access_token_please_try_again_d969614c": "failed to revoke access token; please try again later", + "failed_to_rotate_token_please_try_again_later_59dc9b53": "failed to rotate token; please try again later", + "failed_to_save_file_66b12cc8": "failed to save file", + "failed_to_save_schedule_ceb33e03": "Failed to save schedule", + "failed_to_save_upload_record_e5b8160e": "Failed to save upload record", + "failed_to_send_email_v_36c06c07": "Failed to send email: %v", + "failed_to_send_email_w_b705989e": "failed to send email: %w", + "failed_to_send_notification_title_s_v_360fc6f5": "Failed to send notification (title: %s): %v", + "failed_to_serialize_storage_configuration_w_f9ccaed4": "failed to serialize storage configuration: %w", + "failed_to_set_executable_permissions_w_42a54b5d": "failed to set executable permissions: %w", + "failed_to_update_file_record_dabd703b": "Failed to update file record", + "failed_to_update_last_login_time_please_try_97d1603d": "Failed to update last login time; please try again later", + "failed_to_update_password_please_try_again_later_fb9d6cde": "failed to update password; please try again later", + "failed_to_update_user_information_b39e7d62": "Failed to update user information", + "failed_to_update_user_status_6fdd94ea": "Failed to update user status", + "failed_to_validate_storage_parameters_w_4193c860": "failed to validate storage parameters: %w", + "failed_to_validate_target_storage_parameters_w_103ef6e7": "failed to validate target storage parameters: %w", + "failed_to_write_notification_audit_history_v_b1891968": "Failed to write notification audit history: %v", + "failed_to_write_target_access_logs_batch_d_47059872": "failed to write target access logs (batch %d): %w", + "failed_to_write_target_user_access_logs_batch_9571e74b": "failed to write target user access logs (batch %d): %w", + "failed_to_write_update_archive_w_c5f4d1ca": "failed to write update archive: %w", + "feishu_group_bot_de2e961b": "Feishu group bot", + "fetched_migration_batch_requested_size_d_actual_objects_b53f700f": "Fetched migration batch; requested size: %d, actual objects: %d", + "file_content_does_not_match_its_extension_and_7033268f": "File content does not match its extension and may be unsafe", + "file_record_was_not_found_a4f4b1ab": "File record was not found", + "file_size_must_not_exceed_32_mb_9fef6c20": "File size must not exceed 32 MB", + "file_storage_backend_and_connection_configuration_json_2ee5f58c": "File storage backend and connection configuration (JSON)", + "file_storage_statistics_rebuilt_d_active_records_d_4576eca3": "File storage statistics rebuilt: %d active records, %d files, %d bytes total", + "file_validation_failed_076673fd": "File validation failed", + "file_was_not_found_b5877462": "File was not found", + "fold_minutes_must_be_3_or_5_740e118a": "fold_minutes must be 3 or 5", + "for_example_100123456789_or_channel_name_8e18bf43": "For example -100123456789 or @channel_name", + "found_d_uploads_to_clean_up_in_this_a86fa63c": "Found %d uploads to clean up in this batch", + "geoip_lookup_is_disabled_ed78d892": "GeoIP lookup is disabled", + "geolocation_provider_must_be_disabled_mmdb_ip_api_08bc0f62": "geolocation provider must be disabled, mmdb, ip-api, geojs or ipinfo", + "github_latest_check_interval_must_be_between_5_2470787a": "GitHub latest check interval must be between 5 and 1440 minutes", + "github_release_asset_digest_is_invalid_or_verification_7baba33c": "GitHub Release asset digest is invalid or verification failed", + "github_release_asset_digest_verification_failed_98f540bd": "GitHub Release asset digest verification failed", + "github_release_asset_exceeds_the_size_limit_33eb67f0": "GitHub Release asset exceeds the size limit", + "github_release_asset_is_empty_5a6a9b0a": "GitHub Release asset is empty", + "github_release_asset_name_must_be_a_safe_ee1a4e4e": "GitHub Release asset name must be a safe filename", + "github_release_asset_redirect_limit_exceeded_da3dd30b": "GitHub Release asset redirect limit exceeded", + "github_release_check_completed_947726e7": "GitHub Release check completed", + "github_release_check_completed_content_is_unchanged_dbcf8dc0": "GitHub Release check completed; content is unchanged", + "github_release_request_failed_e37d72ea": "GitHub Release request failed", + "github_release_request_target_is_not_a_public_80de5fc7": "GitHub Release request target is not a public address", + "github_release_sources_must_not_include_remote_url_84826183": "GitHub Release sources must not include remote URL configuration", + "github_returned_an_unexpected_status_s_30454efa": "GitHub returned an unexpected status: %s", + "global_rule_group_03239b06": "Global rule group", + "global_waf_rule_cannot_be_deleted_031e6c8d": "global WAF rule cannot be deleted", + "global_waf_rule_groups_do_not_require_manual_5cc46cb1": "global WAF rule groups do not require manual binding", + "group_399ee693": "group", + "group_id_4acad7d2": "Group ID", + "group_id_must_be_nonzero_caa5a675": "group ID must be nonzero", + "group_name_must_not_be_empty_7b33c238": "group name must not be empty", + "group_primary_key_in_the_of_cf_pointing_34d94c54": "Group primary key in the of_cf_pointing_groups table", + "historical_authentication_source_299cdda4": "Historical authentication source", + "hours_f8af2637": " hours ", + "https_api_base_url_defaults_to_https_api_84cf7d41": "HTTPS API base URL; defaults to https://api.telegram.org", + "id_of_the_tls_certificate_to_renew_2e6077c7": "ID of the TLS certificate to renew", + "id_token_verification_failed_21582415": "ID token verification failed", + "if_specified_this_must_be_valid_feishu_card_1541edd3": "If specified, this must be valid Feishu card JSON", + "image_cache_warmup_completed_processed_d_generated_d_9553c957": "Image cache warmup completed; processed %d, generated %d, hits %d, failed %d", + "image_cache_warmup_parameters_must_not_be_empty_59ff5906": "image cache warmup parameters must not be empty", + "image_quality_0ab5629b": "Image quality", + "image_quality_must_be_low_medium_or_high_3d699272": "image quality must be low, medium or high", + "images_d24c10d3": "Images", + "incorrect_username_or_password_a9a6a83a": "Incorrect username or password", + "ingest_saving_github_release_deployment_archive_04bc7ee9": "[ingest] Saving GitHub Release deployment archive", + "ingest_saving_managed_deployment_archive_ab0eb22f": "[ingest] Saving managed deployment archive", + "initialize_test_e2279a66": "Initialize test", + "internal_server_error_03654907": "Internal server error", + "internal_server_error_cc540d22": "Internal server error", + "internal_system_error_2216034c": "Internal system error", + "invalid_access_mode_parameter_f4f87024": "Invalid access_mode parameter", + "invalid_account_binding_id_47be930f": "Invalid account binding ID", + "invalid_authentication_source_id_8d95b39c": "invalid authentication source ID", + "invalid_cache_rules_for_route_s_0b419f3f": "invalid cache rules for route %s", + "invalid_certificate_pem_content_82644642": "invalid certificate PEM content", + "invalid_cloudflare_connection_source_0f6d01bb": "invalid Cloudflare connection source", + "invalid_cloudflare_failover_check_parameters_w_ef353c9b": "invalid Cloudflare failover check parameters: %w", + "invalid_cloudflare_group_synchronization_parameters_w_545b7a80": "invalid Cloudflare group synchronization parameters: %w", + "invalid_cloudflare_member_synchronization_parameters_w_43aa2390": "invalid Cloudflare member synchronization parameters: %w", + "invalid_cloudflare_node_synchronization_parameters_3f4794f4": "invalid Cloudflare node synchronization parameters", + "invalid_configuration_for_node_s_w_86c7c706": "invalid configuration for node %s: %w", + "invalid_configuration_snapshot_format_6ecb7b3c": "invalid configuration snapshot format", + "invalid_cron_expression_df504099": "Invalid Cron expression", + "invalid_cursor_parameter_315ceadf": "Invalid cursor parameter", + "invalid_custom_request_headers_for_route_s_705aeb8a": "invalid custom request headers for route %s", + "invalid_custom_user_agent_pattern_in_node_s_6a73d813": "invalid custom User-Agent pattern in node %s: %s", + "invalid_deployment_archive_url_only_http_and_https_23d257c7": "invalid deployment archive URL; only HTTP and HTTPS are supported", + "invalid_discovery_token_980df46c": "invalid discovery token", + "invalid_dns_record_value_check_the_domain_name_eb1b034a": "invalid DNS record value; check the domain name or IP address", + "invalid_domain_name_format_614e6afb": "invalid domain name format", + "invalid_email_address_format_4e992d96": "invalid email address format", + "invalid_expression_in_automatic_rule_s_w_a9ea3421": "invalid expression in automatic rule %s: %w", + "invalid_file_id_619affa0": "Invalid file ID", + "invalid_github_release_asset_digest_format_3e38c702": "invalid GitHub Release asset digest format", + "invalid_github_release_metadata_response_190ecf90": "invalid GitHub Release metadata response", + "invalid_github_release_request_parameters_4ceabd70": "invalid GitHub Release request parameters", + "invalid_github_release_selection_mode_d644ae98": "invalid GitHub Release selection mode", + "invalid_github_repository_url_use_https_github_com_4a255a79": "invalid GitHub repository URL; use https://github.com/{owner}/{repo}", + "invalid_graph_data_for_waf_rule_s_w_529ff00b": "invalid graph data for WAF rule %s: %w", + "invalid_graph_for_waf_rule_s_w_8a87d07b": "invalid graph for WAF rule %s: %w", + "invalid_historical_version_snapshot_format_0424b7b0": "invalid historical version snapshot format", + "invalid_id_c0d026c8": "Invalid ID", + "invalid_id_value_s_2be5b057": "Invalid ID value: %s", + "invalid_image_cache_warmup_parameters_v_94273376": "Invalid image cache warmup parameters: %v", + "invalid_image_cache_warmup_parameters_w_285d734c": "invalid image cache warmup parameters: %w", + "invalid_ip_address_format_c6084aa5": "invalid IP address format", + "invalid_ip_address_format_f988e58e": "invalid IP address format", + "invalid_ip_group_type_2c9b1982": "invalid IP group type", + "invalid_ip_match_configuration_in_active_waf_rule_9acb007b": "invalid IP match configuration in active WAF rule %d, node %s: %w", + "invalid_json_format_w_be1f4f8e": "invalid JSON format: %w", + "invalid_login_request_f39483be": "Invalid login request", + "invalid_lookback_format_use_a_duration_such_as_63f37246": "invalid lookback format; use a duration such as 60m or 1h", + "invalid_lookback_minutes_w_d9589ae4": "invalid lookback_minutes: %w", + "invalid_metadata_json_format_59d64290": "Invalid metadata JSON format", + "invalid_node_ip_address_format_6101324f": "Invalid node IP address format", + "invalid_origin_address_format_0f1c1a36": "invalid origin address format", + "invalid_pages_configuration_for_route_s_active_pages_8a53c746": "invalid Pages configuration for route %s: active Pages deployment does not match", + "invalid_pages_configuration_for_route_s_active_pages_bc33eb91": "invalid Pages configuration for route %s: active Pages deployment was not found", + "invalid_pages_configuration_for_route_s_no_pages_f8128192": "invalid Pages configuration for route %s: no Pages project is bound", + "invalid_pages_configuration_for_route_s_pages_deployment_296fe10f": "invalid Pages configuration for route %s: Pages deployment checksum is missing", + "invalid_pages_configuration_for_route_s_pages_module_52fe9ed1": "invalid Pages configuration for route %s: Pages module is unavailable", + "invalid_pages_configuration_for_route_s_pages_project_10fdf9a7": "invalid Pages configuration for route %s: Pages project is disabled", + "invalid_pages_configuration_for_route_s_pages_project_146a248a": "invalid Pages configuration for route %s: Pages project has no active deployment", + "invalid_pages_configuration_for_route_s_pages_project_b6dacd48": "invalid Pages configuration for route %s: Pages project was not found", + "invalid_pages_configuration_for_route_s_w_e055a8aa": "invalid Pages configuration for route %s: %w", + "invalid_pages_configuration_missing_pages_project_id_644ff7ea": "invalid Pages configuration: missing pages_project_id", + "invalid_pages_deployment_source_task_parameters_329e38dc": "invalid Pages deployment source task parameters", + "invalid_pages_entry_file_w_dd997325": "invalid Pages entry file: %w", + "invalid_pages_root_directory_w_288d75ab": "invalid Pages root directory: %w", + "invalid_pages_route_configuration_6e3033f8": "invalid Pages route configuration", + "invalid_parameters_68abb8d5": "invalid parameters", + "invalid_parameters_889c2534": "invalid parameters", + "invalid_port_format_301a25e5": "invalid port format", + "invalid_release_asset_size_d_71275aec": "invalid release asset size: %d", + "invalid_remote_archive_url_use_http_https_without_cec74305": "invalid remote archive URL; use HTTP/HTTPS without user information or fragments", + "invalid_remote_source_archive_size_limit_ed9a749a": "Invalid remote source archive size limit", + "invalid_request_rate_use_a_rate_such_as_7d899f0e": "invalid request rate; use a rate such as 10r/s or 100r/m, or -1 to disable", + "invalid_rule_graph_w_51b132a5": "invalid rule graph: %w", + "invalid_s_format_use_512k_1m_or_a_7ae562cc": "invalid %s format; use 512k, 1m or a number, or leave empty to disable", + "invalid_s_format_use_a_rate_such_as_285ef3fc": "invalid %s format; use a rate such as 10r/s or 100r/m, or leave empty to disable", + "invalid_schedule_id_5439796e": "Invalid schedule ID", + "invalid_since_timestamp_rfc3339_is_required_e4d815e5": "invalid since timestamp; RFC3339 is required", + "invalid_smtp_configuration_use_a_temporary_code_to_707c19c2": "Invalid SMTP configuration; use a temporary code to sign in", + "invalid_spa_fallback_path_65e9fab1": "invalid SPA fallback path", + "invalid_status_code_range_s_ea6a6bba": "invalid status code range: %s", + "invalid_status_code_s_5fcf3c49": "invalid status code: %s", + "invalid_subscription_url_5e4fd4ef": "invalid subscription URL", + "invalid_target_log_database_s_f49a2783": "invalid target log database: %s", + "invalid_task_execution_id_6a0914c6": "Invalid task execution ID", + "invalid_task_type_42c573e7": "Invalid task type", + "invalid_time_range_205ddcad": "Invalid time range", + "invalid_time_range_select_24h_7d_or_30d_7315b848": "invalid time range; select 24h, 7d or 30d", + "invalid_token_id_6493bfb3": "Invalid token ID", + "invalid_until_timestamp_rfc3339_is_required_ff8c418e": "invalid until timestamp; RFC3339 is required", + "invalid_upload_id_5af291c3": "Invalid upload ID", + "invalid_upstream_configuration_for_route_s_868edc8c": "invalid upstream configuration for route %s", + "invalid_upstream_repository_url_1865af39": "invalid upstream repository URL", + "invalid_upstream_version_response_fa50f23a": "Invalid upstream version response", + "invalid_version_identifier_31e5ed95": "Invalid version identifier", + "ip_address_must_not_be_empty_4272c9f3": "IP address must not be empty", + "ip_group_d_was_not_found_7b101cd1": "IP group %d was not found", + "ip_group_is_referenced_by_waf_rules_remove_bd6b2cb2": "IP group is referenced by WAF rules; remove the references first", + "ip_group_name_must_not_be_empty_56680071": "IP group name must not be empty", + "ip_group_was_not_found_21fa23de": "IP group was not found", + "ip_must_not_be_empty_fe4591d4": "ip must not be empty", + "json_mapping_rule_q_did_not_match_any_feb34054": "JSON mapping rule %q did not match any content", + "latitude_must_be_between_90_and_90_0b929ed3": "Latitude must be between -90 and 90", + "leave_empty_to_use_the_built_in_interactive_1817bda4": "Leave empty to use the built-in interactive card", + "less_than_d_minutes_since_the_last_synchronization_7e3c8aac": "Less than %d minutes since the last synchronization; skipping execution", + "log_cleanup_completed_access_log_retention_d_days_c0f19eea": "Log cleanup completed: access log retention %d days, metric retention %d days, deleted %d records", + "log_database_configuration_is_empty_2cf975c3": "log database configuration is empty", + "log_database_switched_from_s_to_s_32746f98": "Log database switched from %s to %s", + "log_database_switched_to_s_writes_resumed_66f1d3fc": "Log database switched to %s; writes resumed", + "log_storage_is_disabled_access_logs_cannot_be_4f3d313d": "Log storage is disabled; access logs cannot be retrieved", + "log_storage_is_disabled_analytics_are_unavailable_2633ef1d": "Log storage is disabled; analytics are unavailable", + "longitude_must_be_between_180_and_180_3b3f5968": "Longitude must be between -180 and 180", + "lookback_must_be_a_whole_number_of_minutes_bd7a069b": "lookback must be a whole number of minutes", + "lookback_window_must_not_exceed_s_ec8227f8": "lookback window must not exceed %s", + "lua_directory_must_not_be_empty_73c45ce8": "lua directory must not be empty", + "lua_file_path_must_not_be_empty_ad5ec475": "lua file path must not be empty", + "main_configuration_path_must_not_be_empty_4257a895": "main configuration path must not be empty", + "match_mode_for_node_s_must_be_and_65944b17": "match mode for node %s must be and or or", + "match_path_is_required_when_the_api_reverse_8b41181e": "match path is required when the API reverse proxy is enabled", + "maximum_number_of_access_tokens_reached_3c769ae3": "Maximum number of access tokens reached", + "maximum_retry_8bf02f4c": "Maximum retry", + "maximum_retry_count_d_reached_d961828b": "Maximum retry count %d reached", + "maximum_retry_count_reached_122b9d92": "Maximum retry count reached", + "member_id_a4c4a726": "Member ID", + "member_id_must_be_nonzero_dbec6bb1": "member ID must be nonzero", + "member_primary_key_in_the_of_cf_pointing_95a864b8": "Member primary key in the of_cf_pointing_members table", + "migrate_file_storage_f03b672a": "Migrate file storage", + "migrate_files_from_active_storage_to_the_target_89752f95": "Migrate files from active storage to the target backend; files remain read-only during migration", + "migration_skipped_identical_file_already_exists_in_target_729b4c99": "[Migration skipped] Identical file already exists in target storage: %s", + "migration_started_reading_file_from_source_storage_s_001c53d6": "[Migration started] Reading file from source storage: %s", + "migration_succeeded_file_migration_completed_s_9c0b6a24": "[Migration succeeded] File migration completed: %s", + "migration_target_postgres_for_a_postgresql_primary_sqlite_a542bb6f": "Migration target: postgres for a PostgreSQL primary, sqlite for a SQLite primary, or clickhouse", + "minutes_c5e1cb2c": " minutes ", + "missing_agent_token_ad736eb5": "missing agent token", + "missing_certificate_file_eb3adf78": "Missing certificate file", + "missing_discovery_token_038af7e9": "missing discovery token", + "missing_pages_deployment_archive_0e2feedd": "missing Pages deployment archive", + "missing_private_key_file_5b2ce6fe": "Missing private key file", + "missing_release_version_identifier_179171c9": "missing release version identifier", + "mmdb_update_interval_must_be_greater_than_0_7b371ae5": "mmdb_update_interval must be greater than 0", + "monitor_scope_must_be_all_or_selected_sites_956be94b": "monitor scope must be all or selected sites", + "months_15e40097": " months ", + "multiple_cloudflare_dns_records_have_the_same_name_0487505e": "multiple Cloudflare DNS records have the same name; remove the duplicates manually first", + "must_be_an_integer_dfd6c0ce": "must be an integer", + "new_password_generated_randomly_if_omitted_acbf0a56": "New password; generated randomly if omitted", + "new_password_must_contain_at_least_8_characters_16160d33": "New password must contain at least 8 characters", + "new_password_s_c7c43a90": "New password: %s\n", + "no_active_configuration_version_60f8a99b": "no active configuration version", + "no_available_authentication_source_is_configured_326f6438": "no available authentication source is configured", + "no_certificate_is_configured_for_route_s_83ba5214": "no certificate is configured for route %s", + "no_cloudflare_connection_is_configured_6ff882c7": "no Cloudflare connection is configured", + "no_compatible_release_was_found_4d9a5ae5": "no compatible release was found", + "no_enabled_rules_are_available_to_publish_8dc1e2c9": "no enabled rules are available to publish", + "no_expired_logs_require_cleanup_access_log_retention_c72c5317": "No expired logs require cleanup (access log retention: %d days; metric retention: %d days)", + "no_github_release_asset_matches_the_configuration_8c8ac16a": "no GitHub Release asset matches the configuration", + "no_ip_addresses_or_ranges_were_parsed_from_1fcb1209": "no IP addresses or ranges were parsed from the JSON subscription", + "no_ip_geolocation_result_was_returned_6cb0600f": "no IP geolocation result was returned", + "no_members_selected_dd913716": "no members selected", + "no_notification_history_requires_cleanup_cutoff_s_a2893d9b": "No notification history requires cleanup (cutoff: %s)", + "no_preview_release_is_currently_available_558f10ae": "no preview release is currently available", + "no_release_asset_was_found_for_the_current_a2eb352a": "no release asset was found for the current platform", + "no_unique_cloudflare_zone_found_for_s_c20b7e4b": "no unique Cloudflare zone found for %s", + "no_valid_file_records_were_found_to_archive_89022def": "No valid file records were found to archive", + "node_2410d860": "node", + "node_id_must_not_be_empty_51a5de58": "node ID must not be empty", + "node_id_must_not_be_empty_5aa87f2e": "node_id must not be empty", + "node_identifier_conflict_please_try_again_8e2acef7": "node identifier conflict; please try again", + "node_ip_must_not_be_empty_f589f59a": "node_ip must not be empty", + "node_ip_must_not_exceed_64_characters_86cbe000": "Node IP must not exceed 64 characters", + "node_is_offline_or_the_synchronization_command_could_c560bf64": "node is offline or the synchronization command could not be sent over WebSocket", + "node_location_name_must_not_exceed_128_characters_87961927": "Node location name must not exceed 128 characters", + "node_name_must_not_be_empty_03dc8468": "node name must not be empty", + "node_name_must_not_be_empty_c7c245c4": "node_name must not be empty", + "node_s_cannot_validate_ip_group_d_37bca244": "node %s cannot validate IP group %d", + "node_s_contains_invalid_browser_tag_s_66a80660": "node %s contains invalid browser tag %s", + "node_s_contains_invalid_cidr_s_3cf57f3e": "node %s contains invalid CIDR %s", + "node_s_contains_invalid_country_code_s_c3a43e69": "node %s contains invalid country code %s", + "node_s_contains_invalid_ip_s_0c3bdf3f": "node %s contains invalid IP %s", + "node_s_contains_invalid_operating_system_tag_s_5f6a6ddd": "node %s contains invalid operating system tag %s", + "node_s_contains_invalid_region_code_s_736f6724": "node %s contains invalid region code %s", + "node_s_failed_to_validate_ip_group_d_ce8c1581": "node %s failed to validate IP group %d: %w", + "node_s_has_no_path_to_a_terminal_8286bb22": "node %s has no path to a terminal node", + "node_s_has_unknown_type_s_f090b1d7": "node %s has unknown type %s", + "node_s_is_not_reachable_from_the_start_5eacc4af": "node %s is not reachable from the start node", + "node_s_must_have_at_least_one_incoming_7c8aac64": "node %s must have at least one incoming edge", + "node_s_must_not_contain_more_than_d_404db8ac": "node %s must not contain more than %d custom User-Agent patterns", + "node_s_output_s_is_connected_to_multiple_05e9cddf": "node %s output %s is connected to multiple targets", + "node_s_output_s_is_not_connected_6de47feb": "node %s output %s is not connected", + "node_s_references_an_invalid_ip_group_id_27bdf145": "node %s references an invalid IP group ID", + "node_s_references_missing_ip_group_d_40d3c647": "node %s references missing IP group %d", + "node_s_requires_at_least_one_pattern_when_480bade2": "node %s requires at least one pattern when custom User-Agent blocking is enabled", + "node_was_not_found_7b451929": "node was not found", + "nonce_mismatch_possible_replay_attack_8b3e6356": "nonce mismatch; possible replay attack", + "none_484d5561": "None", + "not_found_4e51b67d": "not found", + "not_signed_in_c1f65837": "Not signed in", + "notification_channel_test_245d0001": "Notification channel test", + "notification_failed_v_263fc07a": "Notification failed: %v", + "notification_sent_s_s_e52650c7": "Notification sent: [%s] -> %s", + "notification_sent_title_s_content_summary_s_097becda": "Notification sent (title: %s, content summary: %s)", + "observability_replay_minutes_must_be_greater_than_0_fc964575": "observability_replay_minutes must be greater than 0", + "oidc_authentication_sources_require_a_discovery_url_566d40ba": "OIDC authentication sources require a Discovery URL", + "only_administrators_can_create_tokens_with_administrator_privileges_b2e4ad01": "Only administrators can create tokens with administrator privileges", + "only_automatic_and_subscription_ip_groups_support_synchronization_b7d7995c": "only automatic and subscription IP groups support synchronization", + "only_failed_tasks_464688af": "Only failed tasks", + "only_failed_tasks_can_be_retried_279c69a0": "Only failed tasks can be retried", + "only_failed_tasks_can_be_retried_current_status_9346f42a": "only failed tasks can be retried; current status: %s", + "only_jpg_png_and_webp_images_are_supported_c7c3c323": "only JPG, PNG and WEBP images are supported", + "openflare_automatic_ssl_renewal_cf123a50": "OpenFlare automatic SSL renewal", + "openflare_pages_deployment_source_operation_866f0285": "OpenFlare Pages deployment source operation", + "openflare_pages_deployment_source_scan_6932f24c": "OpenFlare Pages deployment source scan", + "openflare_single_certificate_ssl_renewal_a531316e": "OpenFlare single-certificate SSL renewal", + "openflare_uptime_kuma_synchronization_b2168d61": "OpenFlare Uptime Kuma synchronization", + "openflare_waf_ip_group_synchronization_f7213a53": "OpenFlare WAF IP group synchronization", + "openresty_observability_port_must_be_between_1_and_69074f9c": "openresty_observability_port must be between 1 and 65535", + "openrestymainconfigtemplate_must_not_be_empty_3ac2d43c": "OpenRestyMainConfigTemplate must not be empty", + "openrestymainconfigtemplate_must_retain_placeholder_s_cfc00d9c": "OpenRestyMainConfigTemplate must retain placeholder %s", + "operation_not_authorized_invalid_agent_token_307a5b9a": "Operation not authorized: invalid agent token", + "operation_not_authorized_invalid_registration_token_a1264630": "Operation not authorized: invalid registration token", + "operation_not_authorized_invalid_tunnel_token_641a1499": "Operation not authorized: invalid tunnel token", + "operation_not_permitted_286a8d3f": "Operation not permitted", + "origin_address_already_exists_0013db55": "origin address already exists", + "origin_address_is_missing_a_port_7b7c2ebf": "origin address is missing a port", + "origin_address_must_not_be_empty_f49f8b30": "origin address must not be empty", + "origin_path_must_not_contain_a_protocol_8e0af122": "origin path must not contain a protocol", + "origin_path_must_start_with_or_4bf64d2c": "origin path must start with / or ?", + "origin_protocol_must_be_http_or_https_ab499f2a": "origin protocol must be http or https", + "origin_was_not_found_0752eb2d": "Origin was not found", + "other_d2909f16": "Other", + "pages_archives_support_zip_tar_gz_tar_xz_83bca097": "pages archives support zip, tar.gz, tar.xz, tar.bz2, tar and 7z formats only", + "pages_deployment_archive_hash_is_missing_4d740a0d": "pages deployment archive hash is missing", + "pages_deployment_archive_is_not_a_valid_compressed_9b2cefd1": "pages deployment archive is not a valid compressed archive", + "pages_deployment_archive_is_not_a_valid_zip_0dd602e6": "Pages deployment archive is not a valid ZIP file", + "pages_deployment_archive_must_be_a_zip_file_ab5b4552": "Pages deployment archive must be a .zip file", + "pages_deployment_archive_must_not_be_empty_ff4d94e4": "pages deployment archive must not be empty", + "pages_deployment_archive_must_not_exceed_d_mib_3c1a0b6a": "pages deployment archive must not exceed %d MiB", + "pages_deployment_archive_path_is_empty_87435085": "Pages deployment archive path is empty", + "pages_deployment_archive_upload_record_was_not_found_9990e33d": "pages deployment archive upload record was not found", + "pages_deployment_archive_upload_type_mismatch_s_cd5886f7": "pages deployment archive upload type mismatch: %s", + "pages_deployment_archive_was_not_found_w_39714023": "pages deployment archive was not found: %w", + "pages_deployment_does_not_belong_to_this_project_374852db": "pages deployment does not belong to this project", + "pages_deployment_is_not_included_in_the_active_26b4dcae": "pages deployment is not included in the active configuration", + "pages_deployment_must_not_contain_more_than_d_80d2f448": "pages deployment must not contain more than %d files", + "pages_deployment_source_configuration_changed_task_skipped_ed018def": "Pages deployment source configuration changed; task skipped", + "pages_deployment_source_operation_failed_please_try_again_19363b45": "Pages deployment source operation failed; please try again later", + "pages_deployment_source_synchronization_failed_86c51b71": "pages deployment source synchronization failed", + "pages_deployment_source_synchronized_and_published_successfully_0bf1e5dd": "Pages deployment source synchronized and published successfully", + "pages_deployment_source_task_ownership_is_no_longer_35aa212a": "pages deployment source task ownership is no longer valid", + "pages_deployment_source_was_not_found_c9af468e": "pages deployment source was not found", + "pages_deployment_was_not_found_e90bdf07": "pages deployment was not found", + "pages_entry_file_must_not_exceed_512_characters_3e7fb206": "pages entry file must not exceed 512 characters", + "pages_project_has_no_active_deployment_6d7971f7": "pages project has no active deployment", + "pages_project_is_disabled_98847c64": "pages project is disabled", + "pages_project_is_referenced_by_rules_and_cannot_416af718": "pages project is referenced by rules and cannot be deleted", + "pages_project_name_must_not_be_empty_717063ef": "pages project name must not be empty", + "pages_project_or_deployment_is_empty_ddcf5875": "pages project or deployment is empty", + "pages_project_slug_already_exists_b7c6e058": "pages project slug already exists", + "pages_project_slug_may_only_contain_lowercase_letters_56675232": "pages project slug may only contain lowercase letters, digits and hyphens", + "pages_project_was_not_found_b6f46c90": "pages project was not found", + "pages_root_directory_must_not_exceed_512_characters_29d92328": "pages root directory must not exceed 512 characters", + "pages_source_content_is_unchanged_existing_deployment_reactivated_1441d50c": "Pages source content is unchanged; existing deployment reactivated", + "pages_source_scan_completed_recovered_d_leases_compensated_de889c15": "Pages source scan completed: recovered %d leases, compensated %d orphan records, checked %d sources, dispatched %d automatic updates, backlog %d", + "parse_fbff7b50": "Parse", + "parse_task_parameters_w_21ea4f33": "parse task parameters: %w", + "password_changed_successfully_c5299b82": "Password changed successfully", + "password_encryption_failed_please_try_again_ef84e8d4": "password encryption failed; please try again", + "password_login_is_disabled_by_the_administrator_54c5d88a": "Password login is disabled by the administrator", + "password_must_contain_at_least_8_characters_ab81f8d4": "password must contain at least 8 characters", + "password_reset_successfully_bf63ac4e": "Password reset successfully", + "periodically_remove_unused_uploads_historical_notifications_and_expired_68ae5d13": "Periodically remove unused uploads, historical notifications and expired task execution logs", + "pg_dump_is_unavailable_install_the_postgresql_client_759ad5ae": "pg_dump is unavailable; install the PostgreSQL client tools on the server", + "please_sign_in_first_48144f96": "Please sign in first", + "port_must_not_be_empty_e53d74a2": "port must not be empty", + "postgresql_is_disabled_and_sqlite_is_the_primary_3234d0c2": "PostgreSQL is disabled and SQLite is the primary database; migration to PostgreSQL is unavailable", + "postgresql_is_the_primary_database_sqlite_cannot_be_4d638671": "PostgreSQL is the primary database; SQLite cannot be selected for log storage", + "pow_algorithm_for_node_s_must_be_fast_97989167": "PoW algorithm for node %s must be fast or slow", + "pow_challenge_ttl_for_node_s_must_be_87482278": "PoW challenge TTL for node %s must be at least 30 seconds", + "pow_difficulty_for_node_s_must_be_between_4eaec0b8": "PoW difficulty for node %s must be between 1 and 16", + "pow_session_ttl_for_node_s_must_be_98ed3573": "PoW session TTL for node %s must be at least 60 seconds", + "precreated_target_postgresql_partitions_from_s_to_s_0bc5358f": "Precreated target PostgreSQL partitions from %s to %s", + "preparing_group_synchronization_group_s_id_d_enabled_9d8361f3": "Preparing group synchronization: group=%s id=%d enabled=%v target=%s default_proxied=%v", + "preparing_node_synchronization_node_s_id_d_ip_61c8f04a": "Preparing node synchronization: node=%s id=%d ip=%s", + "preparing_to_issue_or_renew_a_certificate_for_18326882": "Preparing to issue or renew a certificate for domains [%s]", + "preparing_to_send_email_to_s_subject_s_1f6fad47": "Preparing to send email to: %s, subject: %s", + "previous_pages_deployment_source_task_lease_expired_4de3ac84": "Previous Pages deployment source task lease expired", + "primary_and_standby_nodes_must_be_different_7f96a264": "primary and standby nodes must be different", + "provide_the_remote_deployment_archive_url_887e4ef2": "provide the remote deployment archive URL", + "querying_next_migration_batch_migrated_d_d_c481c987": "Querying next migration batch; migrated: %d/%d", + "rebuild_file_storage_statistics_fe52d825": "Rebuild file storage statistics", + "rebuild_w_upload_stats_from_active_w_uploads_08023859": "Rebuild w_upload_stats from active w_uploads records, including totals, types, categories and trends", + "rebuilding_file_storage_statistics_active_records_d_60a4199b": "Rebuilding file storage statistics; active records: %d", + "receiving_this_message_confirms_that_the_notification_channel_79c41407": "Receiving this message confirms that the notification channel connectivity test succeeded.", + "recipient_b013dc37": "Recipient", + "recipient_email_to_88e59c15": "Recipient email (To)", + "record_was_not_found_e4ba0386": "Record was not found", + "recover_recovered_expired_source_lease_source_id_d_c4a7c01f": "[recover] Recovered expired source lease: source_id=%d", + "registration_failed_please_try_again_later_a4807a5d": "registration failed; please try again later", + "registration_is_disabled_by_the_administrator_420d53e6": "Registration is disabled by the administrator", + "remote_addr_must_not_be_empty_ba45da61": "remote_addr must not be empty", + "remote_source_redirect_limit_exceeded_3fdd16bf": "Remote source redirect limit exceeded", + "remote_url_sources_do_not_support_update_checks_1ecca79c": "remote URL sources do not support update checks; use Sync now", + "remote_url_sources_must_not_include_github_or_48c0b153": "remote URL sources must not include GitHub or automatic update configuration", + "renew_a_single_specified_acme_certificate_a27fd4e1": "Renew a single specified ACME certificate", + "renewal_certificate_data_saved_9d25aae9": "[Renewal] Certificate data saved", + "renewal_certificate_issued_securely_storing_certificate_and_private_cfaa1b37": "[Renewal] Certificate issued; securely storing certificate and private key in the database...", + "renewal_decrypting_dns_account_credentials_and_acme_account_6ac3acd9": "[Renewal] Decrypting DNS account credentials and ACME account private key...", + "renewal_requested_domains_v_e4bf6bab": "[Renewal] Requested domains: %v", + "renewal_requesting_ssl_certificate_issuance_through_the_acme_98e351ee": "[Renewal] Requesting SSL certificate issuance through the ACME DNS-01 challenge; please wait...", + "renewal_resolving_acme_account_1bc0d71a": "[Renewal] Resolving ACME account...", + "renewal_resolving_dns_account_information_id_d_89687757": "[Renewal] Resolving DNS account information (ID=%d)...", + "renewal_saving_acme_account_changes_15275665": "[Renewal] Saving ACME account changes...", + "renewal_starting_renewal_setting_application_status_to_applying_a167afb3": "[Renewal] Starting renewal; setting application status to applying...", + "request_body_json_77cfb393": "Request body (JSON)", + "request_url_65a1f546": "Request URL", + "reset_the_password_for_a_specified_account_ef0bf7e5": "Reset the password for a specified account", + "resolve_acquiring_source_task_ownership_8bb0e8c7": "[resolve] Acquiring source task ownership", + "resolve_another_source_task_is_running_skipping_task_a4e1db69": "[resolve] Another source task is running; skipping task", + "resolve_resolving_github_release_repo_s_asset_s_6915eea4": "[resolve] Resolving GitHub Release: repo=%s asset=%s", + "resolve_source_configuration_changed_skipping_task_4b3e6282": "[resolve] Source configuration changed; skipping task", + "resolve_source_configuration_or_ownership_changed_skipping_task_d8b6d56a": "[resolve] Source configuration or ownership changed; skipping task", + "resolve_source_no_longer_exists_skipping_task_c4e25edd": "[resolve] Source no longer exists; skipping task", + "result_must_be_success_warning_or_failed_7bca2806": "result must be success, warning or failed", + "retention_days_must_be_between_1_and_3650_3b6a649b": "retention_days must be between 1 and 3650", + "retention_days_must_be_between_1_and_90_371a59a7": "retention_days must be between 1 and 90", + "root_domain_still_contains_domains_delete_them_all_277bc1d6": "root domain still contains domains; delete them all first", + "route_configuration_path_must_not_be_empty_f70cb124": "route configuration path must not be empty", + "route_s_w_0efcb499": "route %s %w", + "rule_graph_contains_multiple_start_nodes_e13cd7e2": "rule graph contains multiple start nodes", + "rule_graph_is_missing_a_start_node_1efe3116": "rule graph is missing a start node", + "rule_graph_must_contain_exactly_one_allow_node_a05ca0b3": "rule graph must contain exactly one allow node", + "rule_graph_must_contain_exactly_one_start_node_3140d98e": "rule graph must contain exactly one start node", + "rule_graph_must_not_contain_cycles_fd6fe797": "rule graph must not contain cycles", + "rule_graph_must_not_contain_more_than_d_3289a255": "rule graph must not contain more than %d edges", + "rule_graph_must_not_contain_more_than_d_76688447": "rule graph must not contain more than %d nodes", + "rule_graph_must_not_exceed_256_kib_855948bb": "rule graph must not exceed 256 KiB", + "rule_graph_schema_version_must_be_d_38d452c4": "rule graph schema_version must be %d", + "rule_was_updated_by_another_operation_reload_it_bba246a7": "Rule was updated by another operation; reload it", + "s_contains_an_empty_domain_name_e4a83686": "%s contains an empty domain name", + "s_contains_duplicate_domain_name_s_15451d4d": "%s contains duplicate domain name %s", + "s_contains_invalid_characters_enter_valid_ip_addresses_59634931": "%s contains invalid characters; enter valid IP addresses or domain names separated by spaces", + "s_contains_invalid_network_range_q_ce167d93": "%s contains invalid network range %q", + "s_contains_unsupported_value_q_6208a8c5": "%s contains unsupported value %q", + "s_domain_name_must_be_lowercase_without_surrounding_59ed3d2c": "%s domain name must be lowercase without surrounding whitespace: %s", + "s_is_not_a_valid_ip_address_643806a5": "%s is not a valid IP address", + "s_is_not_a_valid_ip_range_4835d8bf": "%s is not a valid IP range", + "s_must_be_a_duration_with_a_unit_b97fceb4": "%s must be a duration with a unit, such as 30m or 5s", + "s_must_be_a_json_array_of_strings_dbcb2bd7": "%s must be a JSON array of strings", + "s_must_be_a_nonnegative_integer_0_means_efc94604": "%s must be a nonnegative integer (0 means unlimited)", + "s_must_be_a_nonnegative_integer_0bbc7814": "%s must be a nonnegative integer", + "s_must_be_a_whole_number_of_days_c0ac3325": "%s must be a whole number of days greater than or equal to 1", + "s_must_be_an_integer_between_1_and_3f9a159c": "%s must be an integer between 1 and 2048 (MiB)", + "s_must_be_an_integer_greater_than_0_9553dcdf": "%s must be an integer greater than 0", + "s_must_be_an_integer_or_a_size_2e9fba45": "%s must be an integer or a size with a k/m/g suffix", + "s_must_be_between_100_and_999_6b9e594f": "%s must be between 100 and 999", + "s_must_be_disabled_mmdb_ip_api_geojs_deba53da": "%s must be disabled, mmdb, ip-api, geojs or ipinfo", + "s_must_be_epoll_kqueue_poll_select_rtsig_3177cbf8": "%s must be epoll, kqueue, poll, select, rtsig, /dev/poll, eventport or empty", + "s_must_be_true_or_false_3a8153ff": "%s must be true or false", + "s_must_contain_at_least_one_status_code_15395d65": "%s must contain at least one status code tag", + "s_must_contain_at_most_d_network_ranges_6b0b78c4": "%s must contain at most %d network ranges", + "s_must_not_be_empty_2d5ac8f7": "%s must not be empty", + "s_must_not_be_empty_after_expansion_e814e9ef": "%s must not be empty after expansion", + "s_must_not_contain_newlines_54742aef": "%s must not contain newlines", + "s_must_not_contain_newlines_or_tabs_ff4de73f": "%s must not contain newlines or tabs", + "s_must_not_exceed_d_4b586e57": "%s must not exceed %d", + "s_must_not_exceed_d_bytes_256_kib_2f297bdb": "%s must not exceed %d bytes (256 KiB)", + "s_must_use_a_format_such_as_16_1042e770": "%s must use a format such as \\\"16 16k\\\"", + "s_must_use_a_format_such_as_1_5a9d67e2": "%s must use a format such as \\\"1:2\\\" or \\\"1:2:2\\\"", + "s_supports_at_most_d_domain_names_4f3d5128": "%s supports at most %d domain names", + "scan_acme_certificates_nearing_expiration_and_renew_them_c53289c4": "Scan ACME certificates nearing expiration and renew them automatically", + "scan_due_sources_d_batch_d_ab93c95c": "[scan] Due sources=%d, batch=%d", + "scan_sources_still_due_after_this_batch_d_da73edba": "[scan] Sources still due after this batch=%d", + "scanning_certificates_due_for_renewal_7f9e9ca0": "Scanning certificates due for renewal", + "scanning_unused_uploads_cutoff_s_5c2fbb16": "Scanning unused uploads, cutoff: %s", + "schedule_system_cleanup": "Scheduled system cleanup", + "schedule_was_not_found_6205508a": "Schedule was not found", + "seconds_00fe8dda": " seconds", + "select_a_cname_a_or_aaaa_record_type_80ac9137": "select a CNAME, A or AAAA record type", + "select_a_file_to_upload_a3a97de2": "Select a file to upload", + "select_a_pages_deployment_source_type_70065e2c": "select a Pages deployment source type", + "select_a_valid_cloudflare_dns_account_97385483": "select a valid Cloudflare DNS account", + "select_a_valid_edge_node_0fafe682": "select a valid edge node", + "select_a_valid_target_mode_9a0c6691": "select a valid target mode", + "selected_certificate_was_not_found_3772e60e": "selected certificate was not found", + "send_data_to_an_external_webhook_using_a_068df6b3": "Send data to an external webhook using a custom HTTP POST request.", + "send_email_c268c1b1": "Send email", + "send_failed_v_a04ce62f": "Send failed: %v", + "send_notification_e70e3a00": "Send notification", + "send_system_email_asynchronously_63d68e86": "Send system email asynchronously", + "sequentially_convert_managed_images_to_webp_at_the_05d39459": "Sequentially convert managed images to WebP at the specified quality and store them in permanent cache", + "serialize_289c64ad": "Serialize", + "server_address_server_address_is_not_configured_configure_9e094296": "server address (server_address) is not configured; configure it in system settings and try again", + "server_url_must_not_be_empty_269fe8ad": "server_url must not be empty", + "signature_verification_key_from_the_feishu_group_bot_6090b176": "Signature verification key from the Feishu group bot security settings", + "signing_secret_optional_9dcdda03": "Signing secret (optional)", + "since_and_until_must_be_provided_together_4fdc9431": "since and until must be provided together", + "since_must_be_an_rfc3339_timestamp_ad87e410": "since must be an RFC3339 timestamp", + "source_database_contains_no_logs_skipping_partition_precreation_c2753081": "Source database contains no logs; skipping partition precreation", + "spa_fallback_path_contains_unsupported_characters_14eecbe2": "SPA fallback path contains unsupported characters", + "spa_fallback_path_must_not_contain_or_segments_eee02366": "SPA fallback path must not contain . or .. segments", + "spa_fallback_path_must_not_contain_whitespace_or_a7661241": "SPA fallback path must not contain whitespace or control characters", + "spa_fallback_path_must_not_exceed_512_characters_8be1ecf7": "SPA fallback path must not exceed 512 characters", + "spa_fallback_path_must_point_to_a_specific_dff5905a": "SPA fallback path must point to a specific file", + "spa_fallback_path_must_start_with_91d66834": "SPA fallback path must start with /", + "specified_version_was_not_found_s_eeec8487": "specified version was not found: %s", + "sql_statement_must_not_be_empty_c3a52981": "SQL statement must not be empty", + "start_api_worker_and_scheduler_together_3e558997": "Start API, Worker and Scheduler together", + "start_node_s_must_not_have_incoming_edges_0501404b": "start node %s must not have incoming edges", + "starting_certificate_renewal_id_d_034783ec": "Starting certificate renewal, ID: %d", + "starting_domain_synchronization_domain_s_zone_s_group_49f9cc95": "Starting domain synchronization: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d", + "starting_log_database_migration_s_s_6a587927": "Starting log database migration: %s -> %s", + "starting_notification_event_s_channel_s_recipient_s_561117aa": "Starting notification: event = %s, channel = %s, recipient = %s", + "starting_s_synchronization_name_s_id_d_target_ce5444ff": "Starting %s synchronization: name=%s id=%d target=%s domains=%d", + "starting_sequential_compressed_image_cache_warmup_quality_s_ec690c4f": "Starting sequential compressed image cache warmup, quality: %s, batch size: %d", + "starting_storage_migration_s_s_total_objects_d_43cbe32d": "Starting storage migration: %s -> %s, total objects: %d", + "status_code_must_be_an_integer_between_100_3328ab45": "status_code must be an integer between 100 and 599", + "status_code_must_be_between_d_and_d_f8229da8": "status code must be between %d and %d: %s", + "status_code_range_endpoints_are_reversed_s_afcfd994": "status code range endpoints are reversed: %s", + "status_code_tag_must_not_be_empty_21235ebf": "status code tag must not be empty", + "storage_configuration_updated_active_storage_remains_s_917066ca": "Storage configuration updated; active storage remains %s", + "storage_configuration_updated_directly_failed_migration_task_marked_c9d7148d": "Storage configuration updated directly; failed migration task marked as resolved", + "storage_connectivity_4038ce91": "Storage connectivity", + "storage_connectivity_test_failed_w_08dabbf5": "storage connectivity test failed: %w", + "storage_migration_completed_migrated_d_objects_active_storage_332fb1de": "Storage migration completed; migrated %d objects, active storage switched to %s", + "storage_migration_maintenance_is_active_files_are_read_4bf9e57d": "storage migration maintenance is active; files are read-only", + "subject_line_of_the_email_to_send_c3aa0753": "Subject line of the email to send", + "subscription_content_must_not_exceed_d_bytes_f40541e2": "subscription content must not exceed %d bytes", + "subscription_returned_status_code_d_2c185ec6": "subscription returned status code %d", + "subscription_url_must_use_http_or_https_e43cbb99": "subscription URL must use HTTP or HTTPS", + "succeeded_053461ce": "Succeeded", + "switch_group_primary_and_standby_nodes_based_on_b2d78b3f": "Switch group primary and standby nodes based on node availability and configuration status", + "switch_log_database_da446b57": "Switch log database", + "synchronization_completed_d_ip_addresses_or_ranges_7b645ca2": "Synchronization completed; %d IP addresses or ranges", + "synchronization_succeeded_bca900e9": "Synchronization succeeded", + "synchronize_all_domains_currently_targeting_the_specified_node_3488196a": "Synchronize all domains currently targeting the specified node", + "synchronize_all_domains_in_a_target_group_b2397009": "Synchronize all domains in a target group", + "synchronize_automatic_and_subscription_waf_ip_groups_that_ac3d686e": "Synchronize automatic and subscription WAF IP groups that are due for an update", + "synchronize_cloudflare_dns_records_for_a_single_domain_a06ce84b": "Synchronize Cloudflare DNS records for a single domain", + "synchronize_enabled_proxy_routes_with_uptime_kuma_monitors_8f761ebe": "Synchronize enabled proxy routes with Uptime Kuma monitors", + "synchronizing_proxy_routes_with_uptime_kuma_2d26b523": "Synchronizing proxy routes with Uptime Kuma", + "synchronizing_waf_ip_groups_due_for_an_update_e96ea0b3": "Synchronizing WAF IP groups due for an update", + "system_5b50d7c4": "System", + "system_channel_test_415dd03a": "System channel test", + "system_cleanup_6c08e88d": "System cleanup", + "system_cleanup_completed_removed_d_d_unused_uploads_5eca4159": "System cleanup completed. Removed %d/%d unused uploads, %d notification audit records and %d task execution logs.", + "system_configuration_was_not_found_00210cae": "System configuration was not found", + "system_manual_retry_created_and_enqueued_original_task_3c9cbd5d": "[System] Manual retry created and enqueued (original task ID: %s, retry: %d/%d)", + "system_notification_94b7513e": "System notification", + "system_reserved_file_types_cannot_be_managed_through_9369ffe7": "system-reserved file types cannot be managed through the generic file API", + "system_smtp_configuration_is_incomplete_291bb369": "system SMTP configuration is incomplete", + "system_starting_asynchronous_task_name_s_type_s_90c6e5e2": "[System] Starting asynchronous task [name: %s, type: %s], retry: %d/%d", + "system_starting_asynchronous_task_type_s_caca3e5b": "[System] Starting asynchronous task [type: %s]", + "system_task_enqueued_waiting_for_execution_queue_s_86a50bcc": "[System] Task enqueued; waiting for execution (queue: %s, maximum retries: %d)", + "system_task_failed_duration_d_ms_error_v_63653548": "[System] Task failed; duration: %d ms, error: %v", + "system_task_succeeded_duration_d_ms_result_s_161aa7ca": "[System] Task succeeded; duration: %d ms, result: %s", + "target_group_is_disabled_08c76979": "target group is disabled", + "target_group_must_differ_from_the_current_group_2702a3d3": "target group must differ from the current group", + "target_group_was_not_found_71c24083": "target group was not found", + "target_log_database_febc306d": "Target log database", + "target_log_database_is_already_active_no_migration_92ab4313": "target log database is already active; no migration is required", + "target_storage_configuration_json_884c1a08": "Target storage configuration (JSON)", + "task_cannot_continue_53c9c59a": "Task cannot continue", + "task_completed_0b4c0784": "Task completed: ", + "task_execution_was_not_found_fc632d27": "Task execution was not found", + "task_execution_was_not_found_w_284e6bfc": "task execution was not found: %w", + "task_parameters_must_not_be_empty_bdc11114": "task parameters must not be empty", + "telegram_bot_b0edcbcc": "Telegram bot", + "template_content_must_not_be_empty_6fce7653": "Template content must not be empty", + "template_identifier_already_exists_93895c12": "template identifier already exists", + "template_identifier_must_not_be_empty_7a1e41d3": "Template identifier must not be empty", + "template_name_must_not_be_empty_da00e8dc": "Template name must not be empty", + "template_s_was_not_found_or_is_unavailable_70f4fa4e": "template %s was not found or is unavailable: %w", + "template_was_not_found_37b0cea2": "Template was not found", + "terminal_node_s_must_not_have_outgoing_edges_8ece7ac6": "terminal node %s must not have outgoing edges", + "the_active_node_requires_a_valid_ipv4_address_edf71cfe": "the active node requires a valid IPv4 address", + "the_active_pages_deployment_cannot_be_deleted_9f0bb393": "the active Pages deployment cannot be deleted", + "the_confirmed_version_has_changed_refresh_and_confirm_8a40d0fc": "the confirmed version has changed; refresh and confirm again", + "the_current_rules_have_not_changed_and_cannot_c8b88f7d": "the current rules have not changed and cannot be published again", + "the_currently_signed_in_user_cannot_be_deleted_e47df151": "the currently signed-in user cannot be deleted", + "the_log_database_is_clickhouse_but_clickhouse_is_bc5e49a0": "the log database is ClickHouse, but ClickHouse is disabled. Re-enable ClickHouse and start the server, then run the Switch log database task to migrate to PostgreSQL/SQLite before disabling ClickHouse", + "the_log_database_is_postgresql_but_postgresql_is_01f3b8d3": "the log database is PostgreSQL, but PostgreSQL is disabled and SQLite is the primary database. Run the Switch log database task to migrate to SQLite, or enable PostgreSQL", + "the_log_database_is_sqlite_but_postgresql_is_8cfdfdaa": "the log database is SQLite, but PostgreSQL is the primary database. Run the Switch log database task to migrate to PostgreSQL", + "the_selected_version_is_not_a_preview_release_15d2f434": "the selected version is not a preview release", + "the_specified_entry_file_does_not_exist_in_f029c906": "The specified entry file does not exist in the active deployment", + "the_specified_uploaded_asset_was_not_found_in_942c8d4d": "the specified uploaded asset was not found in the GitHub Release", + "this_access_token_has_no_administrator_privileges_and_9005f2b0": "This access token has no administrator privileges and cannot access admin endpoints", + "this_account_has_no_email_address_contact_an_4e504171": "This account has no email address; contact an administrator to bind an email before signing in", + "this_account_has_no_email_address_use_a_e29bb819": "This account has no email address; use a temporary code to sign in", + "this_configuration_is_managed_by_system_tasks_and_1930e327": "this configuration is managed by system tasks and cannot be changed manually", + "this_dns_account_is_used_by_certificates_and_54842b64": "this DNS account is used by certificates and cannot be deleted", + "this_domain_already_belongs_to_another_target_group_1f6c5889": "this domain already belongs to another target group", + "this_email_address_is_bound_to_another_account_48d6a2fa": "this email address is bound to another account", + "this_endpoint_does_not_allow_access_token_authentication_93ced189": "This endpoint does not allow access-token authentication", + "this_external_account_is_bound_to_another_user_029fce16": "this external account is bound to another user", + "this_is_a_system_notification_channel_connectivity_test_cbd9d668": "This is a system notification channel connectivity test.", + "this_node_is_not_a_tunnelclient_0a666f7a": "This node is not a TunnelClient", + "this_node_is_not_a_tunnelrelay_49925484": "This node is not a TunnelRelay", + "this_origin_is_still_referenced_by_rules_and_fad589f1": "this origin is still referenced by rules and cannot be deleted", + "this_task_cannot_be_retried_be95f1db": "this task cannot be retried", + "time_range_must_not_exceed_30_days_93f190e7": "time range must not exceed 30 days", + "to_subject_and_body_must_not_be_empty_614c8b17": "to, subject and body must not be empty", + "token_name_must_not_be_empty_cd7ba9f4": "Token name must not be empty", + "token_was_not_found_or_access_is_not_b430e116": "token was not found or access is not permitted", + "too_many_login_attempts_try_again_later_3245d6d9": "Too many login attempts; try again later", + "too_many_login_requests_please_try_again_later_4bcd0be6": "too many login requests; please try again later", + "transfer_uploading_file_to_target_storage_s_size_8118b17d": "[Transfer] Uploading file to target storage: %s (size: %d bytes, type: %s)", + "triggered_when_an_administrator_signs_in_successfully_0ff0cfc5": "Triggered when an administrator signs in successfully", + "tunnel_token_must_not_be_empty_67a53536": "tunnel_token must not be empty", + "unable_to_compile_configuration_for_node_s_w_66b38ddb": "unable to compile configuration for node %s: %w", + "unable_to_create_database_export_file_c78edf48": "Unable to create database export file", + "unable_to_enqueue_cloudflare_synchronization_task_4fc208da": "unable to enqueue Cloudflare synchronization task", + "unable_to_export_database_9f34252c": "Unable to export database", + "unable_to_generate_an_available_username_dec720c4": "unable to generate an available username", + "unable_to_get_the_currently_active_configuration_version_6a36cf1a": "unable to get the currently active configuration version: %s", + "unable_to_get_the_currently_active_configuration_version_c8a24858": "unable to get the currently active configuration version: %w", + "unable_to_get_username_from_authentication_source_973bd1db": "unable to get username from authentication source", + "unable_to_identify_the_current_user_9a5f999b": "Unable to identify the current user", + "unable_to_open_database_file_df3e8a7f": "Unable to open database file: ", + "unable_to_read_database_file_information_52111f56": "Unable to read database file information: ", + "unable_to_save_session_please_try_again_58acb751": "Unable to save session; please try again", + "unable_to_serialize_rule_graph_w_48c45bb9": "unable to serialize rule graph: %w", + "unauthorized_access_f0d65927": "Unauthorized access", + "unknown_4d8c1c5b": "Unknown", + "unknown_log_database_configuration_s_98368584": "unknown log database configuration: %s", + "unknown_node_type_s_332fe973": "unknown node type %s", + "unknown_task_type_s_b04735f2": "unknown task type: %s", + "unregistered_task_handler_s_271644ad": "unregistered task handler: %s", + "unsupported_pages_deployment_source_type_5b4321a7": "unsupported Pages deployment source type", + "unsupported_verification_scenario_e2264302": "Unsupported verification scenario", + "until_must_be_an_rfc3339_timestamp_ce5dee46": "until must be an RFC3339 timestamp", + "until_must_be_later_than_since_a541175c": "until must be later than since", + "update_archive_size_mismatch_got_d_want_d_195807e8": "update archive size mismatch: got %d, want %d", + "upstream_response_s_a987ca9f": "Upstream response: %s", + "uptime_kuma_address_is_required_when_the_integration_2a83e4ea": "uptime Kuma address is required when the integration is enabled", + "uptime_kuma_address_must_start_with_http_or_87425cf2": "uptime Kuma address must start with http:// or https://", + "uptime_kuma_integration_is_disabled_skipping_execution_30faa46e": "Uptime Kuma integration is disabled; skipping execution", + "uptime_kuma_password_is_required_when_the_integration_6eed6c4c": "uptime Kuma password is required when the integration is enabled", + "uptime_kuma_synchronization_completed_dfb5cd80": "Uptime Kuma synchronization completed", + "uptime_kuma_synchronization_failed_v_0f0e24be": "Uptime Kuma synchronization failed: %v", + "uptime_kuma_synchronization_is_already_running_skipping_this_3e44a54f": "Uptime Kuma synchronization is already running; skipping this schedule", + "uptime_kuma_username_is_required_when_the_integration_f43559eb": "uptime Kuma username is required when the integration is enabled", + "user_id_is_required_5f1165d8": "User ID is required", + "user_id_must_not_be_empty_07bc47a7": "user ID must not be empty", + "user_was_not_found_8a2ea403": "user was not found", + "user_was_not_found_a82de572": "user was not found", + "username_already_exists_d1ee5409": "username already exists", + "username_must_not_be_empty_390ccdec": "username must not be empty", + "username_must_not_be_empty_fb128ac0": "Username must not be empty\n", + "username_of_the_account_whose_password_will_be_29c265c9": "Username of the account whose password will be reset", + "username_or_email_address_is_already_in_use_18fa1479": "username or email address is already in use", + "username_s_264d6306": "Username: %s\n", + "validate_154723a1": "Validate", + "verification_checking_target_file_consistency_sha_256_s_daef3bb0": "[Verification] Checking target file consistency (SHA-256): %s", + "verification_code_is_incorrect_or_expired_62fb7904": "verification code is incorrect or expired", + "verification_codes_are_being_requested_too_frequently_try_84835f1c": "verification codes are being requested too frequently; try again later", + "verified_file_consistency_check_succeeded_s_09148b16": "[Verified] File consistency check succeeded: %s", + "verify_verifying_archive_structure_and_entry_file_dcc5d692": "[verify] Verifying archive structure and entry file", + "verify_verifying_github_release_archive_and_entry_file_a00c1f16": "[verify] Verifying GitHub Release archive and entry file", + "version_identifier_conflict_please_try_again_c81c8dad": "version identifier conflict; please try again", + "version_must_not_be_empty_09fabcb3": "version must not be empty", + "videos_c20f7618": "Videos", + "waf_ip_group_snapshot_size_d_bytes_exceeds_4bdc932a": "WAF IP group snapshot size %d bytes exceeds the limit of %d bytes", + "waf_ip_group_synchronization_completed_8c799689": "WAF IP group synchronization completed", + "waf_ip_group_synchronization_failed_v_3524af2d": "WAF IP group synchronization failed: %v", + "waf_rule_group_d_was_not_found_4e2d4084": "WAF rule group %d was not found", + "waf_rule_name_must_not_be_empty_92c1a213": "WAF rule name must not be empty", + "waf_rule_operation_failed_4e01c228": "WAF rule operation failed", + "waf_rule_was_not_found_d3a18a2e": "WAF rule was not found", + "waiting_for_image_cache_warmup_lock_quality_s_88fe5819": "Waiting for image cache warmup lock, quality: %s", + "warm_up_compressed_image_cache_bd2f3c3f": "Warm up compressed image cache", + "warning_source_file_is_missing_marking_it_deleted_552b422a": "Warning: source file is missing; marking it deleted and skipping: %s (error: %v)", + "webhook_url_658c6265": "Webhook URL", + "webp_compression_quality_must_be_low_medium_or_866b5127": "WebP compression quality; must be low, medium or high", + "wildcard_domain_names_are_not_supported_e40045cc": "wildcard domain names are not supported", + "years_cda14918": " years ", + "zone_must_be_a_valid_registered_root_domain_cc7d1d33": "zone must be a valid registered root domain", + "zone_was_not_found_86db3707": "Zone was not found", + "public_error_a7274b96774b": "channel name is required", + "public_error_9f24cd1da554": "channel name can only contain letters, numbers, and underscores", + "public_error_1b139d2dcf79": "request URL/address is required", + "public_error_88df46747841": "request URL must use HTTPS protocol for security reasons", + "public_error_29d0f4706829": "payload schema (request body) is required", + "public_error_2127b11c4518": "telegram bot token is required", + "public_error_22bcab9337f1": "payload schema must be a valid JSON format", + "public_error_7c511ae7174a": "captcha is not configured", + "public_error_71759e624238": "invalid session context", + "public_error_b9cbe3209e14": "session mismatch for oauth state", + "public_error_ff5184f57c49": "user context mismatch for oauth binding", + "public_error_b5121d590135": "invalid id", + "public_error_519c7fed477a": "invalid channel id", + "public_error_190df01d2076": "channel not found", + "public_error_cbd9709fbd01": "this notification event is already configured", + "public_error_b93a32137fc1": "custom template is not a valid JSON format", + "public_error_add0341f2152": "cannot enable event without any push channels configured", + "public_error_e59e34f32d9f": "channel name already exists", + "public_error_cdb14a9d218e": "unsupported task type", + "public_error_02fbf8eef643": "either event_key or task_type must be provided", + "public_error_7f64283d20ea": "unsupported built-in event key", + "public_error_eac68fcd302d": "invalid event id", + "public_error_c854c958bd9c": "notification event not found", + "public_error_a37526e5d726": "invalid discovery registration key", + "public_error_452209a49676": "discovery enrollment credential has changed; use the assigned agent token", + "public_error_da739b170c48": "invalid node list parameters", + "public_error_79e7e0ad56c0": "Failed to load nodes" +} diff --git a/pkg/i18n/locales/ru.json b/pkg/i18n/locales/ru.json new file mode 100644 index 00000000..142551c6 --- /dev/null +++ b/pkg/i18n/locales/ru.json @@ -0,0 +1,990 @@ +{ + "a_file_in_the_pages_deployment_archive_exceeds_bf65ffe2": "размер файла в архиве развертывания Pages превышает ограничение по размеру", + "a_new_github_release_deployment_archive_is_available_3ba1aeff": "Доступен новый архив развёртывания из релиза GitHub", + "a_node_ip_is_required_when_ip_locking_2fe806d7": "Если включена привязка к IP, укажите IP-адрес узла", + "a_pages_deployment_source_task_is_already_running_2c10973f": "исходная задача развертывания Pages уже запущена", + "a_preview_release_cannot_be_selected_for_a_b0318833": "предварительный выпуск не может быть выбран для стабильного обновления", + "account_binding_id_must_not_be_empty_84f24a4e": "привязка аккаунта ID не должна быть пустой", + "account_is_disabled_da6e1fd3": "учетная запись отключена", + "activate_atomically_switching_github_release_deployment_80feec83": "[активация] Атомарное переключение развёртывания из релиза GitHub", + "activate_atomically_switching_production_deployment_80300199": "[активировать] Атомарное переключение производственного развертывания", + "activate_source_configuration_or_ownership_changed_deployment_was_e21f8288": "[активировать] Исходная конфигурация или право собственности изменены; развертывание не было переключено", + "administrator_accounts_cannot_be_deleted_f3ae74a2": "учетные записи администратора не могут быть удалены", + "administrator_accounts_cannot_be_disabled_d3eef776": "учетные записи администратора не могут быть отключены", + "administrator_login_c454c61e": "Вход администратора", + "administrator_login_notification_227c9f47": "Уведомление о входе администратора", + "administrator_privileges_cannot_be_revoked_from_the_currently_75858bcb": "права администратора не могут быть отозваны у текущего вошедшего в систему пользователя", + "administrator_user_username_signed_in_at_time_from_a2edfba5": "Администратор {{user.username}} вошел в систему {{time}} с IP {{ip}}.", + "agent_token_and_discovery_token_must_not_both_ca76bf69": "agent_token и discovery_token не должны быть пустыми.", + "agent_token_is_empty_and_discovery_token_is_0349f2b6": "agent_token пусто, а discovery_token не настроено.", + "all_objects_have_been_migrated_93287840": "Все объекты перенесены", + "already_running_the_latest_version_6e865701": "Уже установлена последняя версия", + "an_asset_in_the_same_release_was_replaced_2878205a": "Файл в том же релизе был заменен; требуется подтверждение", + "an_asset_in_the_same_release_was_replaced_40e8d33a": "Файл в том же релизе заменён. Обновите данные и подтвердите текущую версию.", + "an_update_is_already_in_progress_d5dae3bf": "обновление уже в процессе", + "another_storage_migration_task_is_already_running_937fbb49": "другая задача миграции хранилища уже запущена", + "api_base_url_optional_e966f73e": "Базовый URL API (необязательно)", + "api_reverse_proxy_backend_must_be_a_valid_4c68d5b8": "API серверная часть обратного прокси должна быть допустимой HTTP/HTTPS URL", + "api_reverse_proxy_match_path_must_start_with_662f1449": "API Путь сопоставления обратного прокси должен начинаться с '/'", + "archive_contains_an_invalid_path_s_0f89f375": "архив содержит неверный путь: %s", + "archive_path_escapes_the_destination_s_c6d68c24": "путь к архиву выходит за пределы места назначения: %s", + "archives_8f10dc14": "Архивы", + "asynchronous_task_task_name_id_task_id_completed_128e1c5d": "Асинхронная задача {{task_name}} (ID: {{task_id}}) завершена. Статус: {{task_status}}; продолжительность: {{task_duration}} мс.", + "audio_296c632e": "Аудио", + "authentication_source_id_must_not_be_empty_87431a43": "источник аутентификации ID не должен быть пустым", + "authentication_source_is_disabled_7a948dfa": "Источник аутентификации отключен", + "authentication_source_must_not_be_empty_8c3909a8": "источник аутентификации не должен быть пустым", + "authentication_source_name_may_only_contain_letters_digits_71129406": "Имя источника аутентификации может содержать только буквы, цифры, дефисы и подчеркивания и должно начинаться с буквы или цифры.", + "authentication_source_name_must_not_be_empty_f85c91bb": "имя источника аутентификации не должно быть пустым.", + "authentication_source_type_must_be_oidc_3d9726ab": "тип источника аутентификации должен быть oidc", + "automatic_binary_replacement_is_not_supported_on_this_ef024e23": "Автоматическая замена исполняемого файла не поддерживается на этой платформе", + "automatic_ip_group_configuration_must_be_a_json_535c4571": "автоматическая конфигурация группы IP должна быть объектом JSON", + "automatic_notification_triggered_when_an_asynchronous_task_finishes_b0349623": "Автоматическое уведомление, активируемое при завершении асинхронной задачи", + "automatic_rules_completed_matched_d_ips_currently_active_a2f333b5": "Автоматические правила завершены; совпадающие %d IP-адреса, активные в данный момент %d IP-адреса", + "automatic_ssl_renewal_failed_v_b3366eb5": "Автоматическое продление SSL не удалось: %v", + "automatic_ssl_renewal_task_completed_b95fcc39": "Задача автоматического продления SSL завершена", + "automatic_updates_will_be_available_in_a_later_d19f8d56": "Автоматические обновления будут доступны на более позднем этапе и пока должны быть отключены.", + "available_variables_title_description_content_url_to_example_5b046232": "Доступные переменные: $title, $description, $content, $url, $to. Пример: {\"text\": \"$content\"}", + "backend_url_is_required_when_the_api_reverse_8563c33e": "Серверная часть URL требуется, когда включен обратный прокси-сервер API.", + "batch_completed_last_id_d_generated_d_hits_c7365ae6": "Пакет завершен, последний ID: %d, сгенерирован: %d, попадания: %d, неудачный: %d", + "block_response_body_for_node_s_must_not_8df3d52b": "Тело ответа блока для узла %s не должно превышать %d байт", + "block_status_code_for_node_s_must_be_3708ab21": "код состояния блока для узла %s должен быть между 400 и 599", + "bot_access_token_issued_by_botfather_ef54847f": "Токен доступа к боту, выданный BotFather", + "bot_token_7ed368ad": "Токен бота", + "both_latitude_and_longitude_are_required_451abfe8": "Укажите широту и долготу.", + "bucket_minutes_must_be_5_10_15_30_838c287d": "bucket_minutes должно быть 5, 10, 15, 30 или 60.", + "bucket_started_at_must_be_an_rfc3339_timestamp_2d909e88": "bucket_started_at должно быть меткой времени RFC3339", + "built_in_template_identifiers_cannot_be_changed_4e094f31": "Встроенные идентификаторы шаблонов не могут быть изменены.", + "built_in_templates_cannot_be_deleted_dd8af484": "встроенные шаблоны нельзя удалить", + "cannot_be_retried_93537915": "нельзя повторить попытку", + "cannot_convert_configuration_s_value_s_to_a_30f99a83": "невозможно преобразовать значение конфигурации %s '%s' в логическое значение: %w", + "cannot_convert_configuration_s_value_s_to_a_d601455b": "невозможно преобразовать значение конфигурации %s '%s' в десятичное число: %w", + "cannot_convert_configuration_s_value_s_to_an_bea99a42": "невозможно преобразовать значение конфигурации %s «%s» в целое число: %w", + "captcha_verification_failed_missing_verification_credential_2b70bfd8": "CAPTCHA проверка не удалась: отсутствуют учетные данные для проверки", + "captcha_verification_failed_or_expired_please_try_again_83606d54": "CAPTCHA проверка не удалась или срок ее действия истек; пожалуйста, попробуйте еще раз", + "certificate_directory_must_not_be_empty_71b05ede": "каталог сертификатов не должен быть пустым", + "certificate_file_path_must_not_be_empty_9fb64a51": "путь к файлу сертификата не должен быть пустым", + "certificate_id_48409532": "ID сертификата", + "certificate_id_must_be_nonzero_742f9d12": "сертификат ID должен быть ненулевым", + "certificate_issuance_failed_v_c18caa92": "Не удалось выдать сертификат: %v", + "certificate_s_id_d_is_not_acme_managed_0703919b": "Сертификат %s (ID=%d) не ACME-управляется и не может быть продлен автоматически (поставщик: %s)", + "certificate_s_is_not_acme_managed_and_cannot_e4d6469f": "сертификат %s не ACME-управляется и не может быть продлен автоматически", + "certificate_s_renewed_successfully_5ddb1448": "Сертификат %s успешно продлен.", + "channel_test_notification_2a7a7219": "Уведомление о тестировании канала", + "check_checking_github_release_repo_s_asset_s_9f927192": "[проверка] Проверка релиза GitHub: репозиторий=%s файл=%s", + "check_existing_files_89d94c02": "Проверка существующих файлов", + "check_github_provider_backoff_source_id_d_status_9b362b41": "[проверить] GitHub откат провайдера: source_id=%d status=%d retry_at=%s", + "check_or_synchronize_a_pages_project_deployment_source_bdfc6949": "Проверка или синхронизация источника развёртывания Pages", + "check_source_is_running_another_task_skipping_source_ca7ae2fd": "[проверить] Источник выполняет другую задачу; пропуск: source_id=%d", + "checking_cloudflare_group_primary_and_standby_node_status_e2df040e": "Проверка статуса основного и резервного узла группы Cloudflare", + "cleaned_up_d_task_execution_logs_d_high_2352273f": "Почищены логи выполнения задач %d (%d высокочастотные, %d низкочастотные)", + "cleaning_up_expired_logs_using_log_database_retention_c4163ab0": "Очистка журналов с истекшим сроком действия с использованием хранения базы данных журналов и отдельного хранения коротких метрик...", + "cleaning_up_notification_audit_history_retaining_the_last_6074bdfe": "Очистка истории аудита уведомлений; сохранение последних 7 дней...", + "cleaning_up_task_execution_logs_retaining_3_days_5f4a5eb2": "Очистка журналов выполнения задач: хранение 3 дней для высокочастотных задач и 30 дней для низкочастотных задач...", + "cleanup_compensating_unreferenced_deployment_archive_records_eb828b5c": "[очистка] Компенсация неиспользуемых записей архива развертывания", + "cleanup_completed_fce56fa2": "Очистка завершена", + "cleanup_orphan_candidates_d_compensated_d_referenced_d_21a3822b": "[очистка] кандидаты-сироты=%d, компенсация=%d, ссылка=%d, аренда занята=%d, недопустимые маркеры=%d, пропущен=%d, неудача=%d", + "clickhouse_is_disabled_migration_to_clickhouse_is_unavailable_7bbf52f9": "ClickHouse отключен; миграция на ClickHouse недоступна", + "client_id_and_client_secret_are_required_before_b1904682": "client ID и Client Secret необходимы перед включением источника аутентификации.", + "cloudflare_api_request_failed_8fbea766": "Ошибка запроса к API Cloudflare", + "cloudflare_api_request_failed_w_1f530c09": "Cloudflare API не удалось выполнить запрос: %w", + "cloudflare_api_retry_limit_exceeded_7e3673cc": "Cloudflare API превышен лимит повторов", + "cloudflare_api_token_is_inactive_4a0ca3bf": "токен Cloudflare API неактивен", + "cloudflare_dns_synchronization_failed_08371454": "Ошибка синхронизации DNS Cloudflare", + "cloudflare_domain_synchronization_8b6eca62": "Синхронизация домена Cloudflare", + "cloudflare_domain_synchronization_succeeded_b3b55a0a": "Домен Cloudflare синхронизирован", + "cloudflare_domain_synchronization_succeeded_s_s_proxied_v_5b0eed54": "Cloudflare синхронизация домена выполнена успешно: %s -> %s (прокси=%v)", + "cloudflare_group_synchronization_d98bfd8f": "Синхронизация группы Cloudflare", + "cloudflare_node_failover_check_57f894bc": "Проверка переключения узлов Cloudflare при сбое", + "cloudflare_node_failover_check_completed_switched_d_groups_1dd7476d": "Cloudflare проверка переключения узла завершена; переключил группы %d", + "cloudflare_node_failover_check_failed_error_v_52840d43": "Cloudflare проверка переключения узла при сбое не удалась: ошибка=%v", + "cloudflare_node_synchronization_ed5311ec": "Синхронизация узлов Cloudflare", + "cloudflare_resource_was_not_found_8194db78": "Ресурс Cloudflare не найден", + "cloudflare_s_synchronization_completed_s_d_domains_3d341ca3": "Cloudflare %s синхронизация завершена: домены %s, %d", + "cloudflare_s_synchronization_completed_s_has_no_domain_302881ff": "Cloudflare %s синхронизация завершена: %s не имеет участников домена", + "cloudflare_s_synchronization_completed_s_s_d_domains_6aa7c0fd": "Cloudflare %s синхронизация завершена: домены %s -> %s, %d", + "compensate_orphaned_archives_recover_expired_leases_and_check_dd5bf0ee": "Удаление архивов без связанных записей, восстановление просроченных блокировок источников и проверка обновлений в источниках GitHub, отслеживающих последний релиз", + "complete_https_endpoint_url_for_example_https_api_321a9514": "Заполните конечную точку HTTPS URL, например https://api.example.com/webhook.", + "complete_json_configuration_of_the_target_storage_backend_2a07e361": "Полные настройки целевого хранилища в формате JSON", + "config_must_not_be_empty_a3a68460": "конфигурация не должна быть пустой", + "configuration_contains_an_additional_json_value_b7e289fa": "конфигурация содержит дополнительное значение JSON", + "configuration_key_already_exists_6d757a67": "Конфигурационный ключ уже существует", + "configuration_key_must_not_be_empty_e5cb19c0": "Конфигурационный ключ не должен быть пустым.", + "configuration_must_not_be_null_32b5f98f": "конфигурация не должна быть нулевой", + "configuration_path_is_not_initialized_6d59fe3e": "путь конфигурации не инициализирован", + "configuration_value_must_not_be_empty_20e379d9": "Значение конфигурации не должно быть пустым.", + "configure_telegram_bot_notifications_9bffc349": "Настройте уведомления бота Telegram.", + "configured_cloudflare_api_token_is_invalid_0453f265": "настроенный Cloudflare API токен недействителен", + "connecting_to_smtp_server_s_d_username_s_0186b04e": "Подключение к серверу SMTP: %s:%d, имя пользователя: %s", + "content_of_the_email_to_send_77c2373e": "Содержимое письма для отправки", + "copied_d_access_logs_through_id_d_d6f86229": "Скопированы журналы доступа %d (через id=%d)", + "copied_d_user_access_logs_through_id_d_f80f625f": "Скопированы журналы доступа пользователей %d (через id=%d)", + "copied_s_d_records_through_id_d_ee8dd5f7": "Скопировано %s: записи %d (через id=%d)", + "copy_log_data_and_switch_the_log_database_0576d9c2": "Копирование журналов и переключение базы после успешного переноса. На время переноса запись журналов приостанавливается.", + "copy_the_webhook_url_from_the_feishu_group_d0638f81": "Скопируйте URL вебхука из настроек группового бота Feishu", + "current_migration_batch_completed_progress_d_d_42eb3c40": "Текущий пакет миграции завершен. Прогресс: %d/%d", + "current_password_is_incorrect_36d208b0": "текущий пароль неверен", + "current_storage_has_no_objects_to_migrate_active_533b6b0a": "В текущем хранилище нет объектов для миграции; активная память переключена на %s", + "custom_card_json_template_optional_be356c13": "Свой шаблон карточки в формате JSON (необязательно)", + "custom_notification_channel_f6ac176a": "Пользовательский канал уведомлений", + "custom_user_agent_pattern_in_node_s_must_1ae42a76": "пользовательский шаблон User-Agent в узле %s не должен быть пустым", + "custom_user_agent_pattern_in_node_s_must_3372446d": "пользовательский шаблон User-Agent в узле %s не должен превышать %d байт", + "d_d_domain_record_no_longer_exists_removing_397ee9a8": "[%d/%d] Запись домена больше не существует; удаление потерянного члена: member_id=%d zone_domain_id=%d", + "d_d_failed_domain_s_member_id_d_6e126949": "[%d/%d] Ошибка: домен=%s member_id=%d error=%v", + "d_d_failed_to_remove_orphaned_member_member_7b0a42ab": "[%d/%d] Не удалось удалить потерянный элемент: member_id=%d error=%v", + "d_d_succeeded_domain_s_2eb31693": "[%d/%d] Успешно: домен=%s", + "d_d_synchronizing_domain_s_member_id_d_593b8a8a": "[%d/%d] Синхронизация домена %s (member_id=%d прокси=%v)", + "d_days_4a67fe38": "%d дней", + "d_hours_e7b10e79": "%d часов", + "d_minutes_2bc2266f": "%d минут", + "d_seconds_26064316": "%d секунд", + "database_is_not_initialized_0ab7ea2d": "База данных не инициализирована", + "database_update_updating_file_path_s_s_35e484ff": "[Обновление базы данных] Обновление пути к файлу: %s -> %s", + "days_5ab7e0f9": " дни ", + "default_chat_id_optional_a982d25a": "ID чата по умолчанию (необязательно)", + "default_recipient_chat_id_used_when_the_notification_248a34c6": "Чат получателя по умолчанию ID, используется, когда событие уведомления не имеет целей.", + "deleted_d_notification_history_records_cutoff_s_ca03970f": "Удалены записи истории уведомлений %d (ограничение: %s)", + "deleted_successfully_5223f91b": "Удален успешно", + "deliver_notifications_through_a_feishu_group_bot_webhook_c851f7b2": "Доставляйте уведомления через вебхук группового бота Feishu.", + "deployment_archive_at_the_url_exceeds_the_size_51b0e2ec": "Размер архива развертывания по адресу URL превышает ограничение.", + "deployment_source_saved_but_the_initial_check_could_a4d74ed9": "Источник развертывания сохранен, но первоначальную проверку не удалось поставить в очередь; запустите ручную проверку позже", + "destination_email_address_bb129242": "Адрес электронной почты назначения", + "development_builds_do_not_support_automatic_updates_bb3a2946": "разрабатываемые сборки не поддерживают автоматические обновления", + "discovery_registration_response_is_missing_node_id_or_4823814c": "Ответ на регистрацию обнаружения отсутствует node_id или agent_token", + "dispatch_automatic_update_dispatched_source_id_d_execution_72ffca53": "[отправка] Автоматическое обновление отправлено: source_id=%d execution_id=%s ревизия=%s", + "dispatch_system_notifications_asynchronously_across_multiple_channels_0c5c685b": "Фоновая отправка системных уведомлений по нескольким каналам.", + "dns_account_parameters_are_incomplete_fbcb0fad": "DNS параметры аккаунта неполные", + "documents_2687ccdb": "Документы", + "domain_already_exists_a59a13de": "домен уже существует", + "domain_does_not_belong_to_this_zone_bb1bc93f": "домен не принадлежит этой зоне", + "domain_is_bound_to_a_proxy_route_remove_ad0a62b7": "домен привязан к прокси-маршруту; сначала удали обвязку", + "domain_name_must_not_be_empty_60a8eab8": "доменное имя не должно быть пустым", + "domain_synchronization_failed_domain_s_member_id_d_31ec4ac5": "Ошибка синхронизации домена: домен=%s member_id=%d ошибка=%v", + "domain_synchronization_failed_member_id_d_error_v_36d3d285": "Не удалось синхронизировать домен: member_id=%d error=%v", + "domain_synchronization_succeeded_domain_s_desired_target_s_18cedff2": "Домен синхронизирован: домен=%s целевой_адрес=%s проксирование=%v группа=%s режим_цели=%s", + "domain_synchronization_succeeded_member_id_d_2906410a": "Синхронизация домена прошла успешно: member_id=%d", + "domain_was_not_found_e3b18d21": "Домен не найден", + "download_downloading_github_release_asset_repo_s_asset_f3760632": "[загрузка] Загрузка файла релиза GitHub: репозиторий=%s файл=%s", + "download_fetching_remote_deployment_archive_9c58137d": "[скачать] Получение архива удаленного развертывания", + "duplicate_edge_id_s_59427f0d": "дубликат края ID %s", + "duplicate_node_id_s_6f7764f9": "дубликат узла ID %s", + "edge_id_must_not_be_empty_049cfbf2": "Укажите ID ребра", + "edge_s_references_missing_source_node_s_b14a641b": "Ребро %s ссылается на отсутствующий исходный узел %s", + "edge_s_references_missing_target_node_s_f38f63a4": "Ребро %s ссылается на отсутствующий целевой узел %s", + "edge_s_source_port_s_is_not_valid_445c7518": "Ребро %s: исходный порт %s недопустим для узла %s", + "email_address_is_already_registered_ca59e824": "адрес электронной почты уже зарегистрирован", + "email_address_is_already_registered_f861225c": "адрес электронной почты уже зарегистрирован", + "email_address_must_not_be_empty_28e55875": "адрес электронной почты не должен быть пустым", + "email_address_must_not_be_empty_2e477cb1": "адрес электронной почты не должен быть пустым", + "email_address_or_verification_code_is_missing_2b477d98": "адрес электронной почты или код подтверждения отсутствуют", + "email_body_db38bae9": "Тело письма", + "email_notification_channel_70b4e93c": "Канал уведомлений по электронной почте", + "email_notifications_use_the_global_smtp_settings_no_9b002fd1": "Уведомления по электронной почте используют глобальные настройки SMTP; здесь не требуется настройка сервера.", + "email_sent_successfully_to_38e67ca0": "Письмо успешно отправлено на адрес: ", + "email_subject_98a8f04e": "Тема письма", + "enter_a_cloudflare_api_token_00262e30": "введите токен Cloudflare API", + "enter_a_valid_json_request_body_template_variables_34eba3ef": "Введите допустимое тело запроса JSON; поддерживаются переменные шаблона", + "enter_the_certificate_id_f0f6ff01": "Укажите ID сертификата", + "enter_the_cloudflare_target_group_id_830db5d3": "Укажите ID целевой группы Cloudflare", + "enter_the_cloudflare_target_member_id_4a062db8": "Укажите ID домена в группе Cloudflare", + "enter_the_complete_request_url_using_https_08ac14d8": "Укажите полный URL запроса с протоколом HTTPS", + "enter_the_deployment_archive_download_url_2fe91a17": "введите загрузку архива развертывания URL", + "enter_the_email_body_html_is_supported_ca9985b5": "Введите тело письма (HTML поддерживается)", + "enter_the_email_subject_852e1d02": "Введите тему письма", + "enter_the_secret_if_signature_verification_is_enabled_fd5f494e": "Введите секрет, если у бота включена проверка подписи", + "enter_the_telegram_bot_token_dcc672a5": "Введите токен бота Telegram", + "enter_username_e4435775": "Введите имя пользователя: ", + "error_user_s_was_not_found_81b9b991": "Ошибка: пользователь «%s» не найден.\n", + "event_key_2f70299f": "Ключ события", + "executor_is_not_configured_5d41479d": "Исполнитель не настроен", + "existing_files_were_found_use_the_storage_migration_e099a954": "были найдены существующие файлы; используйте задачу миграции хранилища для переключения серверов хранения", + "expression_in_automatic_rule_d_must_not_be_f696189a": "выражение в автоматическом правиле %d не должно быть пустым", + "external_account_binding_information_is_incomplete_180d928a": "информация о привязке внешней учетной записи неполная", + "extracted_executable_exceeds_the_size_limit_9461c1e6": "размер извлеченного исполняемого файла превышает предельный размер", + "extracted_pages_deployment_exceeds_the_size_limit_4cbef66f": "Развертывание извлеченных страниц превышает ограничение по размеру", + "failed_to_back_up_current_executable_w_3addff1b": "не удалось создать резервную копию текущего исполняемого файла: %w", + "failed_to_bind_parameters_5c410c6f": "Не удалось привязать параметры", + "failed_to_bind_parameters_provide_a_valid_array_b77c7672": "Не удалось привязать параметры; предоставить действительный массив идентификаторов файлов", + "failed_to_check_existing_files_w_a851b86d": "не удалось проверить существующие файлы: %w", + "failed_to_clean_up_expired_logs_v_e8791024": "Не удалось очистить просроченные журналы: %v", + "failed_to_clean_up_github_release_temporary_file_00bf359d": "Не удалось удалить временный файл релиза GitHub", + "failed_to_clean_up_remote_source_temporary_file_d721acd8": "Не удалось очистить временный файл удаленного источника.", + "failed_to_clean_up_task_execution_logs_v_376be8e5": "Не удалось очистить журналы выполнения задач: %v", + "failed_to_clean_up_upload_id_d_v_cf2710b1": "Не удалось очистить загрузку [ID:%d]: %v", + "failed_to_clear_target_access_logs_w_9ff48e23": "не удалось очистить журналы целевого доступа: %w", + "failed_to_clear_target_user_access_logs_w_d7c2ad8c": "не удалось очистить журналы доступа целевых пользователей: %w", + "failed_to_close_update_archive_w_0a42c60e": "не удалось закрыть архив обновлений: %w", + "failed_to_compile_waf_rule_s_w_4392b9dc": "не удалось скомпилировать правило WAF %s: %w", + "failed_to_copy_s_w_b0890ed6": "не удалось скопировать %s: %w", + "failed_to_count_active_uploads_v_eccd0ce3": "Не удалось подсчитать активные загрузки: %v", + "failed_to_count_notification_history_for_cleanup_v_87bcd35d": "Не удалось подсчитать историю уведомлений для очистки: %v", + "failed_to_count_tokens_please_try_again_later_d6be07bb": "не удалось посчитать жетоны; пожалуйста, попробуйте еще раз позже", + "failed_to_create_retry_execution_record_w_250258f4": "не удалось создать запись повторного выполнения: %w", + "failed_to_create_task_execution_record_w_7bafae3b": "не удалось создать запись выполнения задачи: %w", + "failed_to_create_token_please_try_again_later_268e7466": "не удалось создать токен; пожалуйста, попробуйте еще раз позже", + "failed_to_create_update_archive_w_0e1ae531": "не удалось создать архив обновлений: %w", + "failed_to_create_update_directory_w_f3414e06": "не удалось создать каталог обновлений: %w", + "failed_to_create_update_download_request_w_ca826654": "не удалось создать запрос на загрузку обновления: %w", + "failed_to_create_update_request_92de6fa7": "не удалось создать запрос на обновление", + "failed_to_create_user_fe2017cc": "Не удалось создать пользователя", + "failed_to_delete_cloudflare_dns_record_905df10a": "не удалось удалить запись Cloudflare DNS", + "failed_to_delete_file_485d2b67": "Не удалось удалить файл", + "failed_to_delete_notification_history_v_4308a791": "Не удалось удалить историю уведомлений: %v", + "failed_to_delete_schedule_82d7ea83": "Не удалось удалить расписание.", + "failed_to_delete_token_please_try_again_later_8a65e358": "не удалось удалить токен; пожалуйста, попробуйте еще раз позже", + "failed_to_delete_user_7cf04bd0": "Не удалось удалить пользователя", + "failed_to_dispatch_task_40f01141": "Не удалось отправить задачу", + "failed_to_download_deployment_archive_from_url_ece95602": "Не удалось загрузить архив развертывания с URL.", + "failed_to_download_github_release_asset_445f42e6": "Не удалось скачать файл релиза GitHub", + "failed_to_download_subscription_w_73516643": "не удалось загрузить подписку: %w", + "failed_to_download_update_asset_http_d_8bfeec63": "не удалось загрузить ресурс обновления: HTTP %d", + "failed_to_download_update_asset_w_15a3a529": "не удалось загрузить ресурс обновления: %w", + "failed_to_drain_log_write_queue_w_a0166ebc": "не удалось очистить очередь записи журнала: %w", + "failed_to_encrypt_password_v_92ab4495": "Не удалось зашифровать пароль: %v\n", + "failed_to_enqueue_pages_deployment_source_task_821d3915": "не удалось поставить в очередь исходную задачу развертывания Pages", + "failed_to_enqueue_retry_task_w_2c39873e": "не удалось поставить в очередь задачу повторной попытки: %w", + "failed_to_enqueue_retry_v_5d869929": "Не удалось поставить повторную попытку: %v", + "failed_to_enqueue_task_v_a7c1aed2": "Не удалось поставить задачу в очередь: %v", + "failed_to_enqueue_task_w_9e65a379": "не удалось поставить задачу в очередь: %w", + "failed_to_enqueue_verification_email_task_please_try_ac66b52f": "не удалось поставить в очередь задачу проверки электронной почты; пожалуйста, попробуйте еще раз", + "failed_to_execute_automatic_rule_w_2271cde6": "не удалось выполнить автоматическое правило: %w", + "failed_to_extract_update_asset_w_692981b3": "не удалось извлечь актив обновления: %w", + "failed_to_fetch_upstream_version_e19bb5de": "Не удалось получить исходную версию.", + "failed_to_generate_random_password_v_f0fea5b7": "Не удалось сгенерировать случайный пароль: %v\n", + "failed_to_generate_token_c78757e3": "не удалось сгенерировать токен", + "failed_to_generate_verification_code_please_try_again_d83752a4": "не удалось сгенерировать проверочный код; пожалуйста, попробуйте еще раз", + "failed_to_get_certificate_record_id_d_v_6d59f24e": "Не удалось получить запись сертификата ID=%d: %v", + "failed_to_get_certificate_record_w_214148cd": "не удалось получить запись сертификата: %w", + "failed_to_get_latest_version_w_fca8b7de": "не удалось получить последнюю версию: %w", + "failed_to_get_log_database_status_da58b757": "Не удалось получить статус базы данных журналов.", + "failed_to_get_preview_version_w_d61ed476": "не удалось получить предварительную версию: %w", + "failed_to_get_specified_version_w_63e83abe": "не удалось получить указанную версию: %w", + "failed_to_get_token_list_please_try_again_96e34095": "не удалось получить список токенов; пожалуйста, попробуйте еще раз позже", + "failed_to_get_user_list_27ad5285": "Не удалось получить список пользователей", + "failed_to_initialize_log_database_configuration_w_adfaefb2": "не удалось инициализировать конфигурацию базы данных журналов: %w", + "failed_to_initialize_log_storage_20fdb546": "Не удалось инициализировать хранилище журналов.", + "failed_to_initialize_system_options_783dd4ca": "Не удалось инициализировать системные параметры.", + "failed_to_initialize_test_storage_instance_w_e45afe79": "не удалось инициализировать экземпляр тестового хранилища: %w", + "failed_to_list_s_members_name_s_id_fa26680a": "Не удалось составить список участников %s: name=%s id=%d error=%v", + "failed_to_load_active_node_group_s_active_4064ae7a": "Не удалось загрузить активный узел: group=%s active_node_id=%d error=%v", + "failed_to_load_group_group_id_d_error_af85458e": "Не удалось загрузить группу: group_id=%d error=%v", + "failed_to_load_member_context_member_id_d_5dba6ac0": "Не удалось загрузить контекст элемента: member_id=%d error=%v", + "failed_to_load_node_node_id_d_error_e404b970": "Не удалось загрузить узел: node_id=%d error=%v", + "failed_to_locate_current_executable_w_8e100f0b": "не удалось найти текущий исполняемый файл: %w", + "failed_to_migrate_zone_d_conflicts_w_fd8934cb": "не удалось перенести зону (%d конфликты): %w", + "failed_to_open_file_756f4b8e": "Не удалось открыть файл", + "failed_to_parse_current_storage_configuration_w_6eb179f7": "не удалось проанализировать текущую конфигурацию хранилища: %w", + "failed_to_parse_directory_display_configuration_w_b45c310f": "не удалось проанализировать конфигурацию отображения каталога: %w", + "failed_to_parse_email_parameters_v_2d5f065f": "Не удалось проанализировать параметры электронной почты: %v", + "failed_to_parse_email_parameters_w_0af9b984": "не удалось проанализировать параметры электронной почты: %w", + "failed_to_parse_image_cache_warmup_parameters_w_80cc27e9": "не удалось проанализировать параметры прогрева кэша изображений: %w", + "failed_to_parse_json_subscription_w_7171f213": "не удалось разобрать подписку JSON: %w", + "failed_to_parse_notification_parameters_v_7c7c0d1a": "Не удалось проанализировать параметры уведомления: %v", + "failed_to_parse_parameters_w_d098976c": "не удалось проанализировать параметры: %w", + "failed_to_parse_preview_version_information_8c63f444": "не удалось проанализировать информацию о предварительной версии", + "failed_to_parse_target_storage_configuration_w_dedc816a": "не удалось проанализировать конфигурацию целевого хранилища: %w", + "failed_to_parse_version_information_9f1ec272": "не удалось проанализировать информацию о версии", + "failed_to_precreate_target_postgresql_partitions_w_97bd4df9": "не удалось предварительно создать целевые разделы PostgreSQL: %w", + "failed_to_process_file_9a1e91ff": "Не удалось обработать файл", + "failed_to_process_image_id_d_v_f89e5f9a": "Не удалось обработать изображение [ID:%d]: %v", + "failed_to_query_access_trends_740f8684": "Не удалось запросить тенденции доступа: ", + "failed_to_query_active_users_d78284d9": "Не удалось запросить активных пользователей: ", + "failed_to_query_browser_distribution_483ef559": "Не удалось запросить распространение браузера: ", + "failed_to_query_file_count_b2b5322f": "Не удалось запросить количество файлов.", + "failed_to_query_file_list_a50606c2": "Не удалось запросить список файлов", + "failed_to_query_file_record_f2d055b2": "Не удалось запросить запись файла", + "failed_to_query_image_upload_records_v_72a8a849": "Не удалось запросить записи загрузки изображений: %v", + "failed_to_query_images_for_cache_warmup_w_daee6ed8": "не удалось запросить изображения для прогрева кэша: %w", + "failed_to_query_unused_uploads_v_4b22bf86": "Не удалось запросить неиспользуемые загрузки: %v", + "failed_to_query_unused_uploads_w_915f2d8f": "не удалось запросить неиспользуемые загрузки: %w", + "failed_to_query_user_information_w_612e6300": "не удалось запросить информацию о пользователе: %w", + "failed_to_query_user_v_31dc6f02": "Не удалось запросить пользователя: %v\n", + "failed_to_read_aggregate_statistics_v_1fafe12a": "Не удалось прочитать совокупную статистику: %v", + "failed_to_read_log_database_configuration_w_8d46eff5": "не удалось прочитать конфигурацию базы данных журналов: %w", + "failed_to_read_log_database_v_f6136632": "Не удалось прочитать базу данных журналов: %v", + "failed_to_read_log_database_w_0502e3ef": "не удалось прочитать базу данных журналов: %w", + "failed_to_read_source_access_log_time_range_d9667403": "не удалось прочитать временной диапазон журнала доступа к источнику: %w", + "failed_to_read_source_user_access_log_time_691f3bb2": "не удалось прочитать временной диапазон журнала доступа исходного пользователя: %w", + "failed_to_read_subscription_content_w_e3665864": "не удалось прочитать содержимое подписки: %w", + "failed_to_read_username_v_f5ab6d40": "Не удалось прочитать имя пользователя: %v\n", + "failed_to_rebuild_file_storage_statistics_v_3a12ad55": "Не удалось восстановить статистику хранилища файлов: %v", + "failed_to_remove_previous_backup_w_9f9ce6cb": "не удалось удалить предыдущую резервную копию: %w", + "failed_to_render_verification_email_template_w_6191557f": "не удалось отобразить шаблон письма для подтверждения: %w", + "failed_to_replace_current_executable_w_a286fcde": "не удалось заменить текущий исполняемый файл: %w", + "failed_to_reset_password_v_628117ce": "Не удалось сбросить пароль: %v\n", + "failed_to_resolve_current_executable_path_w_fd2316e8": "не удалось разрешить текущий путь к исполняемому файлу: %w", + "failed_to_resolve_github_release_request_target_4f4e6fdc": "Не удалось определить адрес назначения запроса к релизу GitHub", + "failed_to_retrieve_file_records_fd17cc6c": "Не удалось получить записи файлов.", + "failed_to_retry_task_147b8518": "Не удалось повторить задачу", + "failed_to_revoke_access_token_please_try_again_d969614c": "не удалось отозвать токен доступа; пожалуйста, попробуйте еще раз позже", + "failed_to_rotate_token_please_try_again_later_59dc9b53": "не удалось повернуть токен; пожалуйста, попробуйте еще раз позже", + "failed_to_save_file_66b12cc8": "не удалось сохранить файл", + "failed_to_save_schedule_ceb33e03": "Не удалось сохранить расписание.", + "failed_to_save_upload_record_e5b8160e": "Не удалось сохранить запись загрузки.", + "failed_to_send_email_v_36c06c07": "Не удалось отправить электронное письмо: %v", + "failed_to_send_email_w_b705989e": "не удалось отправить электронное письмо: %w", + "failed_to_send_notification_title_s_v_360fc6f5": "Не удалось отправить уведомление (заголовок: %s): %v", + "failed_to_serialize_storage_configuration_w_f9ccaed4": "не удалось сериализовать конфигурацию хранилища: %w", + "failed_to_set_executable_permissions_w_42a54b5d": "не удалось установить разрешения на исполняемый файл: %w", + "failed_to_update_file_record_dabd703b": "Не удалось обновить запись файла.", + "failed_to_update_last_login_time_please_try_97d1603d": "Не удалось обновить время последнего входа в систему; пожалуйста, попробуйте еще раз позже", + "failed_to_update_password_please_try_again_later_fb9d6cde": "не удалось обновить пароль; пожалуйста, попробуйте еще раз позже", + "failed_to_update_user_information_b39e7d62": "Не удалось обновить информацию о пользователе.", + "failed_to_update_user_status_6fdd94ea": "Не удалось обновить статус пользователя.", + "failed_to_validate_storage_parameters_w_4193c860": "не удалось проверить параметры хранилища: %w", + "failed_to_validate_target_storage_parameters_w_103ef6e7": "не удалось проверить параметры целевого хранилища: %w", + "failed_to_write_notification_audit_history_v_b1891968": "Не удалось записать историю аудита уведомлений: %v", + "failed_to_write_target_access_logs_batch_d_47059872": "не удалось записать целевые журналы доступа (пакет %d): %w", + "failed_to_write_target_user_access_logs_batch_9571e74b": "не удалось записать журналы доступа целевых пользователей (пакет %d): %w", + "failed_to_write_update_archive_w_c5f4d1ca": "не удалось записать архив обновлений: %w", + "feishu_group_bot_de2e961b": "Групповой бот Feishu", + "fetched_migration_batch_requested_size_d_actual_objects_b53f700f": "Получен пакет миграции; запрошенный размер: %d, фактические объекты: %d", + "file_content_does_not_match_its_extension_and_7033268f": "Содержимое файла не соответствует его расширению и может быть небезопасным.", + "file_record_was_not_found_a4f4b1ab": "Запись файла не найдена", + "file_size_must_not_exceed_32_mb_9fef6c20": "Размер файла не должен превышать 32 MB", + "file_storage_backend_and_connection_configuration_json_2ee5f58c": "Серверная часть хранилища файлов и конфигурация подключения (JSON)", + "file_storage_statistics_rebuilt_d_active_records_d_4576eca3": "Перестроена статистика хранилища файлов: %d активных записей, %d файлов, %d всего байт.", + "file_validation_failed_076673fd": "Проверка файла не удалась", + "file_was_not_found_b5877462": "Файл не найден", + "fold_minutes_must_be_3_or_5_740e118a": "fold_minutes должно быть 3 или 5", + "for_example_100123456789_or_channel_name_8e18bf43": "Например -100123456789 или @channel_name", + "found_d_uploads_to_clean_up_in_this_a86fa63c": "В этом пакете найдено %d загрузок, которые необходимо удалить.", + "geoip_lookup_is_disabled_ed78d892": "Поиск GeoIP отключен", + "geolocation_provider_must_be_disabled_mmdb_ip_api_08bc0f62": "Провайдер геолокации должен быть disabled, mmdb, ip-api, geojs или ipinfo", + "github_latest_check_interval_must_be_between_5_2470787a": "GitHub интервал последней проверки должен составлять от 5 до 1440 минут.", + "github_release_asset_digest_is_invalid_or_verification_7baba33c": "Контрольная сумма файла релиза GitHub некорректна или не прошла проверку", + "github_release_asset_digest_verification_failed_98f540bd": "Не удалось проверить контрольную сумму файла релиза GitHub", + "github_release_asset_exceeds_the_size_limit_33eb67f0": "Размер файла релиза GitHub превышает ограничение", + "github_release_asset_is_empty_5a6a9b0a": "Файл релиза GitHub пуст", + "github_release_asset_name_must_be_a_safe_ee1a4e4e": "Имя файла релиза GitHub должно быть безопасным именем файла", + "github_release_asset_redirect_limit_exceeded_da3dd30b": "Превышено допустимое число перенаправлений при загрузке файла релиза GitHub", + "github_release_check_completed_947726e7": "Проверка релиза GitHub завершена", + "github_release_check_completed_content_is_unchanged_dbcf8dc0": "Проверка релиза GitHub завершена; содержимое не изменилось", + "github_release_request_failed_e37d72ea": "Ошибка запроса к релизу GitHub", + "github_release_request_target_is_not_a_public_80de5fc7": "Адрес назначения запроса к релизу GitHub должен быть публичным", + "github_release_sources_must_not_include_remote_url_84826183": "Источник релиза GitHub не должен содержать настройки удалённого URL", + "github_returned_an_unexpected_status_s_30454efa": "GitHub вернул неожиданный статус: %s", + "global_rule_group_03239b06": "Глобальная группа правил", + "global_waf_rule_cannot_be_deleted_031e6c8d": "глобальное правило WAF невозможно удалить", + "global_waf_rule_groups_do_not_require_manual_5cc46cb1": "глобальные группы правил WAF не требуют ручной привязки", + "group_399ee693": "группа", + "group_id_4acad7d2": "ID группы", + "group_id_must_be_nonzero_caa5a675": "группа ID должна быть ненулевой", + "group_name_must_not_be_empty_7b33c238": "имя группы не должно быть пустым", + "group_primary_key_in_the_of_cf_pointing_34d94c54": "Групповой первичный ключ в таблице of_cf_pointing_groups.", + "historical_authentication_source_299cdda4": "Ранее настроенный источник аутентификации", + "hours_f8af2637": " часы ", + "https_api_base_url_defaults_to_https_api_84cf7d41": "HTTPS API основание URL; по умолчанию https://api.telegram.org", + "id_of_the_tls_certificate_to_renew_2e6077c7": "ID сертификата TLS для продления", + "id_token_verification_failed_21582415": "ID проверка токена не удалась", + "if_specified_this_must_be_valid_feishu_card_1541edd3": "Если указано, это должна быть действительная карта Feishu JSON", + "image_cache_warmup_completed_processed_d_generated_d_9553c957": "Прогрев кэша изображений завершен; обработано %d, сгенерировано %d, обращения %d, неудачно %d", + "image_cache_warmup_parameters_must_not_be_empty_59ff5906": "параметры прогрева кэша изображений не должны быть пустыми", + "image_quality_0ab5629b": "Качество изображения", + "image_quality_must_be_low_medium_or_high_3d699272": "Качество изображения должно быть low, medium или high", + "images_d24c10d3": "Изображения", + "incorrect_username_or_password_a9a6a83a": "Неверное имя пользователя или пароль", + "ingest_saving_github_release_deployment_archive_04bc7ee9": "[сохранение] Сохранение архива развёртывания из релиза GitHub", + "ingest_saving_managed_deployment_archive_ab0eb22f": "[ingest] Сохранение архива управляемого развертывания", + "initialize_test_e2279a66": "Инициализировать тест", + "internal_server_error_03654907": "Внутренняя ошибка сервера", + "internal_server_error_cc540d22": "Внутренняя ошибка сервера", + "internal_system_error_2216034c": "Внутренняя системная ошибка", + "invalid_access_mode_parameter_f4f87024": "Неверный параметр access_mode", + "invalid_account_binding_id_47be930f": "Неверный ID привязки учётной записи", + "invalid_authentication_source_id_8d95b39c": "неверный источник аутентификации ID", + "invalid_cache_rules_for_route_s_0b419f3f": "неверные правила кэша для маршрута %s", + "invalid_certificate_pem_content_82644642": "недействительный сертификат PEM содержание", + "invalid_cloudflare_connection_source_0f6d01bb": "неверный источник соединения Cloudflare", + "invalid_cloudflare_failover_check_parameters_w_ef353c9b": "неверные параметры проверки аварийного переключения Cloudflare: %w", + "invalid_cloudflare_group_synchronization_parameters_w_545b7a80": "неверные параметры групповой синхронизации Cloudflare: %w", + "invalid_cloudflare_member_synchronization_parameters_w_43aa2390": "неверные параметры синхронизации участников Cloudflare: %w", + "invalid_cloudflare_node_synchronization_parameters_3f4794f4": "неверные параметры синхронизации узла Cloudflare", + "invalid_configuration_for_node_s_w_86c7c706": "неверная конфигурация для узла %s: %w", + "invalid_configuration_snapshot_format_6ecb7b3c": "неверный формат снимка конфигурации", + "invalid_cron_expression_df504099": "Недопустимое выражение Cron", + "invalid_cursor_parameter_315ceadf": "Неверный параметр курсора", + "invalid_custom_request_headers_for_route_s_705aeb8a": "недопустимые заголовки пользовательских запросов для маршрута %s", + "invalid_custom_user_agent_pattern_in_node_s_6a73d813": "неверный пользовательский шаблон User-Agent в узле %s: %s", + "invalid_deployment_archive_url_only_http_and_https_23d257c7": "неверный архив развертывания URL; поддерживаются только HTTP и HTTPS", + "invalid_discovery_token_980df46c": "неверный токен открытия", + "invalid_dns_record_value_check_the_domain_name_eb1b034a": "неверное значение записи DNS; проверьте доменное имя или адрес IP", + "invalid_domain_name_format_614e6afb": "неверный формат доменного имени", + "invalid_email_address_format_4e992d96": "неверный формат адреса электронной почты", + "invalid_expression_in_automatic_rule_s_w_a9ea3421": "недопустимое выражение в автоматическом правиле %s: %w", + "invalid_file_id_619affa0": "Неверный ID файла", + "invalid_github_release_asset_digest_format_3e38c702": "Неверный формат контрольной суммы файла релиза GitHub", + "invalid_github_release_metadata_response_190ecf90": "Некорректный ответ с метаданными релиза GitHub", + "invalid_github_release_request_parameters_4ceabd70": "Некорректные параметры запроса к релизу GitHub", + "invalid_github_release_selection_mode_d644ae98": "Некорректный режим выбора релиза GitHub", + "invalid_github_repository_url_use_https_github_com_4a255a79": "недействительный репозиторий GitHub URL; используйте https://github.com/{owner}/{repo}", + "invalid_graph_data_for_waf_rule_s_w_529ff00b": "неверные данные графика для правила WAF %s: %w", + "invalid_graph_for_waf_rule_s_w_8a87d07b": "неверный график для правила WAF %s: %w", + "invalid_historical_version_snapshot_format_0424b7b0": "неверный формат снимка исторической версии", + "invalid_id_c0d026c8": "Неверный ID", + "invalid_id_value_s_2be5b057": "Недопустимое значение ID: %s", + "invalid_image_cache_warmup_parameters_v_94273376": "Неверные параметры прогрева кэша изображений: %v", + "invalid_image_cache_warmup_parameters_w_285d734c": "неверные параметры прогрева кэша изображений: %w", + "invalid_ip_address_format_c6084aa5": "неверный формат адреса IP", + "invalid_ip_address_format_f988e58e": "неверный формат адреса IP", + "invalid_ip_group_type_2c9b1982": "неверный тип группы IP", + "invalid_ip_match_configuration_in_active_waf_rule_9acb007b": "недопустимая конфигурация IP соответствует активному правилу WAF %d, узел %s: %w", + "invalid_json_format_w_be1f4f8e": "неверный формат JSON: %w", + "invalid_login_request_f39483be": "Неверный запрос на вход", + "invalid_lookback_format_use_a_duration_such_as_63f37246": "неверный формат ретроспективного анализа; используйте продолжительность, например 60 минут или 1 час.", + "invalid_lookback_minutes_w_d9589ae4": "неверный lookback_minutes: %w", + "invalid_metadata_json_format_59d64290": "Неверный формат метаданных JSON.", + "invalid_node_ip_address_format_6101324f": "Неверный формат IP-адреса узла", + "invalid_origin_address_format_0f1c1a36": "неверный формат исходного адреса", + "invalid_pages_configuration_for_route_s_active_pages_8a53c746": "неверная конфигурация страниц для маршрута %s: активное развертывание страниц не соответствует", + "invalid_pages_configuration_for_route_s_active_pages_bc33eb91": "неверная конфигурация Pages для маршрута %s: активное развертывание Pages не найдено.", + "invalid_pages_configuration_for_route_s_no_pages_f8128192": "неверная конфигурация Pages для маршрута %s: ни один проект Pages не привязан", + "invalid_pages_configuration_for_route_s_pages_deployment_296fe10f": "неверная конфигурация страниц для маршрута %s: отсутствует контрольная сумма развертывания страниц.", + "invalid_pages_configuration_for_route_s_pages_module_52fe9ed1": "неверная конфигурация Pages для маршрута %s: модуль Pages недоступен.", + "invalid_pages_configuration_for_route_s_pages_project_10fdf9a7": "неверная конфигурация Pages для маршрута %s: проект Pages отключен.", + "invalid_pages_configuration_for_route_s_pages_project_146a248a": "неверная конфигурация Pages для маршрута %s: проект Pages не имеет активного развертывания.", + "invalid_pages_configuration_for_route_s_pages_project_b6dacd48": "неверная конфигурация Pages для маршрута %s: проект Pages не найден.", + "invalid_pages_configuration_for_route_s_w_e055a8aa": "неверная конфигурация страниц для маршрута %s: %w", + "invalid_pages_configuration_missing_pages_project_id_644ff7ea": "неверная конфигурация страниц: отсутствует pages_project_id", + "invalid_pages_deployment_source_task_parameters_329e38dc": "недопустимые параметры исходной задачи развертывания страниц", + "invalid_pages_entry_file_w_dd997325": "неверный файл записи страниц: %w", + "invalid_pages_root_directory_w_288d75ab": "неверный корневой каталог страниц: %w", + "invalid_pages_route_configuration_6e3033f8": "неверная конфигурация маршрута страниц", + "invalid_parameters_68abb8d5": "неверные параметры", + "invalid_parameters_889c2534": "неверные параметры", + "invalid_port_format_301a25e5": "неверный формат порта", + "invalid_release_asset_size_d_71275aec": "неверный размер файла релиза: %d", + "invalid_remote_archive_url_use_http_https_without_cec74305": "неверный удаленный архив URL; используйте HTTP/HTTPS без информации о пользователе или фрагментов", + "invalid_remote_source_archive_size_limit_ed9a749a": "Недопустимый предел размера архива удаленного источника.", + "invalid_request_rate_use_a_rate_such_as_7d899f0e": "недействительная частота запросов; используйте скорость, например 10 об/с или 100 об/мин, или -1, чтобы отключить", + "invalid_rule_graph_w_51b132a5": "неверный график правил: %w", + "invalid_s_format_use_512k_1m_or_a_7ae562cc": "неверный формат %s; используйте 512k, 1m или число или оставьте пустым, чтобы отключить", + "invalid_s_format_use_a_rate_such_as_285ef3fc": "неверный формат %s; используйте скорость, например 10 об/с или 100 об/мин, или оставьте пустым, чтобы отключить", + "invalid_schedule_id_5439796e": "Неверный ID расписания", + "invalid_since_timestamp_rfc3339_is_required_e4d815e5": "недействителен с временной метки; RFC3339 требуется", + "invalid_smtp_configuration_use_a_temporary_code_to_707c19c2": "Неверная конфигурация SMTP; используйте временный код для входа", + "invalid_spa_fallback_path_65e9fab1": "неверный резервный путь SPA", + "invalid_status_code_range_s_ea6a6bba": "неверный диапазон кода состояния: %s", + "invalid_status_code_s_5fcf3c49": "неверный код состояния: %s", + "invalid_subscription_url_5e4fd4ef": "недействительная подписка URL", + "invalid_target_log_database_s_f49a2783": "Недопустимая целевая база журналов: %s", + "invalid_task_execution_id_6a0914c6": "Неверный ID выполнения задачи", + "invalid_task_type_42c573e7": "Неверный тип задачи", + "invalid_time_range_205ddcad": "Неверный диапазон времени", + "invalid_time_range_select_24h_7d_or_30d_7315b848": "неверный диапазон времени; выберите 24 часа, 7 дней или 30 дней", + "invalid_token_id_6493bfb3": "Неверный ID токена", + "invalid_until_timestamp_rfc3339_is_required_ff8c418e": "недействителен до отметки времени; RFC3339 требуется", + "invalid_upload_id_5af291c3": "Неверный ID загруженного файла", + "invalid_upstream_configuration_for_route_s_868edc8c": "неверная восходящая конфигурация для маршрута %s", + "invalid_upstream_repository_url_1865af39": "недопустимый вышестоящий репозиторий URL", + "invalid_upstream_version_response_fa50f23a": "Неверный ответ исходной версии", + "invalid_version_identifier_31e5ed95": "Неверный идентификатор версии", + "ip_address_must_not_be_empty_4272c9f3": "Адрес IP не должен быть пустым.", + "ip_group_d_was_not_found_7b101cd1": "IP группа %d не найдена", + "ip_group_is_referenced_by_waf_rules_remove_bd6b2cb2": "На группу IP ссылаются правила WAF; сначала удали ссылки", + "ip_group_name_must_not_be_empty_56680071": "Укажите название IP-группы", + "ip_group_was_not_found_21fa23de": "IP-группа не найдена", + "ip_must_not_be_empty_fe4591d4": "ip не должен быть пустым", + "json_mapping_rule_q_did_not_match_any_feb34054": "JSON правило сопоставления %q не соответствует никакому содержимому", + "latitude_must_be_between_90_and_90_0b929ed3": "Широта должна быть между -90 и 90.", + "leave_empty_to_use_the_built_in_interactive_1817bda4": "Оставьте пустым, чтобы использовать встроенную интерактивную карточку", + "less_than_d_minutes_since_the_last_synchronization_7e3c8aac": "Менее %d минут с момента последней синхронизации; пропуск выполнения", + "log_cleanup_completed_access_log_retention_d_days_c0f19eea": "Очистка журнала завершена: срок хранения журнала доступа %d дней, срок хранения показателей %d дней, удаленные %d записи.", + "log_database_configuration_is_empty_2cf975c3": "Конфигурация базы данных журналов пуста", + "log_database_switched_from_s_to_s_32746f98": "База данных журналов изменена с %s на %s.", + "log_database_switched_to_s_writes_resumed_66f1d3fc": "База данных журналов переключена на %s; пишет возобновлено", + "log_storage_is_disabled_access_logs_cannot_be_4f3d313d": "Хранение журналов отключено; журналы доступа не могут быть получены", + "log_storage_is_disabled_analytics_are_unavailable_2633ef1d": "Хранение журналов отключено; аналитика недоступна", + "longitude_must_be_between_180_and_180_3b3f5968": "Долгота должна быть между -180 и 180.", + "lookback_must_be_a_whole_number_of_minutes_bd7a069b": "период ретроспективного анализа должен составлять целое число минут", + "lookback_window_must_not_exceed_s_ec8227f8": "Окно ретроспективного анализа не должно превышать %s", + "lua_directory_must_not_be_empty_73c45ce8": "каталог lua не должен быть пустым", + "lua_file_path_must_not_be_empty_ad5ec475": "Путь к файлу lua не должен быть пустым", + "main_configuration_path_must_not_be_empty_4257a895": "основной путь конфигурации не должен быть пустым", + "match_mode_for_node_s_must_be_and_65944b17": "Режим сопоставления узла %s должен быть and или or", + "match_path_is_required_when_the_api_reverse_8b41181e": "путь сопоставления требуется, когда включен обратный прокси-сервер API", + "maximum_number_of_access_tokens_reached_3c769ae3": "Достигнуто максимальное количество токенов доступа", + "maximum_retry_8bf02f4c": "Максимальное количество повторов", + "maximum_retry_count_d_reached_d961828b": "Достигнуто максимальное количество повторов %d.", + "maximum_retry_count_reached_122b9d92": "Достигнуто максимальное количество повторов", + "member_id_a4c4a726": "ID домена в группе", + "member_id_must_be_nonzero_dbec6bb1": "член ID должен быть ненулевым", + "member_primary_key_in_the_of_cf_pointing_95a864b8": "Первичный ключ участника в таблице of_cf_pointing_members.", + "migrate_file_storage_f03b672a": "Перенос файлового хранилища", + "migrate_files_from_active_storage_to_the_target_89752f95": "Перенос файлов из активного хранилища в целевое. Во время переноса файлы доступны только для чтения.", + "migration_skipped_identical_file_already_exists_in_target_729b4c99": "[Миграция пропущена] Идентичный файл уже существует в целевом хранилище: %s", + "migration_started_reading_file_from_source_storage_s_001c53d6": "[Миграция началась] Чтение файла из исходного хранилища: %s", + "migration_succeeded_file_migration_completed_s_9c0b6a24": "[Миграция выполнена успешно] Миграция файла завершена: %s", + "migration_target_postgres_for_a_postgresql_primary_sqlite_a542bb6f": "Цель миграции: postgres для основного сервера PostgreSQL, sqlite для основного сервера SQLite или Clickhouse.", + "minutes_c5e1cb2c": " минуты ", + "missing_agent_token_ad736eb5": "отсутствует токен агента", + "missing_certificate_file_eb3adf78": "Отсутствует файл сертификата", + "missing_discovery_token_038af7e9": "недостающий токен открытия", + "missing_pages_deployment_archive_0e2feedd": "отсутствует архив развертывания Pages", + "missing_private_key_file_5b2ce6fe": "Отсутствует файл закрытого ключа", + "missing_release_version_identifier_179171c9": "отсутствует идентификатор версии выпуска", + "mmdb_update_interval_must_be_greater_than_0_7b371ae5": "mmdb_update_interval должно быть больше 0", + "monitor_scope_must_be_all_or_selected_sites_956be94b": "областью мониторинга должны быть все или выбранные сайты", + "months_15e40097": " месяцы ", + "multiple_cloudflare_dns_records_have_the_same_name_0487505e": "несколько записей Cloudflare DNS имеют одно и то же имя; сначала удалите дубликаты вручную", + "must_be_an_integer_dfd6c0ce": "должно быть целым числом", + "new_password_generated_randomly_if_omitted_acbf0a56": "Новый пароль; генерируется случайным образом, если опущено", + "new_password_must_contain_at_least_8_characters_16160d33": "Новый пароль должен содержать не менее 8 символов", + "new_password_s_c7c43a90": "Новый пароль: %s\n", + "no_active_configuration_version_60f8a99b": "нет активной версии конфигурации", + "no_available_authentication_source_is_configured_326f6438": "доступный источник аутентификации не настроен", + "no_certificate_is_configured_for_route_s_83ba5214": "для маршрута %s не настроен сертификат", + "no_cloudflare_connection_is_configured_6ff882c7": "нет Cloudflare соединение настроено", + "no_compatible_release_was_found_4d9a5ae5": "совместимая версия не найдена", + "no_enabled_rules_are_available_to_publish_8dc1e2c9": "нет активных правил, доступных для публикации", + "no_expired_logs_require_cleanup_access_log_retention_c72c5317": "Никакие журналы с истекшим сроком действия не требуют очистки (сохранение журнала доступа: %d дней; хранение показателей: %d дней)", + "no_github_release_asset_matches_the_configuration_8c8ac16a": "В релизе GitHub нет файла, соответствующего настройкам", + "no_ip_addresses_or_ranges_were_parsed_from_1fcb1209": "адреса или диапазоны IP не были проанализированы из подписки JSON", + "no_ip_geolocation_result_was_returned_6cb0600f": "нет IP результат геолокации не был возвращен", + "no_members_selected_dd913716": "участники не выбраны", + "no_notification_history_requires_cleanup_cutoff_s_a2893d9b": "Никакая история уведомлений не требует очистки (ограничение: %s)", + "no_preview_release_is_currently_available_558f10ae": "предварительная версия в настоящее время недоступна", + "no_release_asset_was_found_for_the_current_a2eb352a": "для текущей платформы не найден актив выпуска", + "no_unique_cloudflare_zone_found_for_s_c20b7e4b": "для %s не найдена уникальная зона Cloudflare", + "no_valid_file_records_were_found_to_archive_89022def": "Не найдено действительных файловых записей для архивирования", + "node_2410d860": "узел", + "node_id_must_not_be_empty_51a5de58": "Укажите ID узла", + "node_id_must_not_be_empty_5aa87f2e": "node_id не должно быть пустым", + "node_identifier_conflict_please_try_again_8e2acef7": "конфликт идентификаторов узлов; пожалуйста, попробуйте еще раз", + "node_ip_must_not_be_empty_f589f59a": "node_ip не должно быть пустым", + "node_ip_must_not_exceed_64_characters_86cbe000": "Длина IP-адреса узла не должна превышать 64 символа", + "node_is_offline_or_the_synchronization_command_could_c560bf64": "узел находится в автономном режиме или команду синхронизации невозможно отправить через WebSocket.", + "node_location_name_must_not_exceed_128_characters_87961927": "Название местоположения узла не должно превышать 128 символов.", + "node_name_must_not_be_empty_03dc8468": "имя узла не должно быть пустым", + "node_name_must_not_be_empty_c7c245c4": "node_name не должно быть пустым", + "node_s_cannot_validate_ip_group_d_37bca244": "узел %s не может проверить IP группу %d", + "node_s_contains_invalid_browser_tag_s_66a80660": "узел %s содержит неверный тег браузера %s", + "node_s_contains_invalid_cidr_s_3cf57f3e": "узел %s содержит недопустимый CIDR %s", + "node_s_contains_invalid_country_code_s_c3a43e69": "узел %s содержит неверный код страны %s", + "node_s_contains_invalid_ip_s_0c3bdf3f": "узел %s содержит недопустимый IP %s", + "node_s_contains_invalid_operating_system_tag_s_5f6a6ddd": "узел %s содержит неверный тег операционной системы %s", + "node_s_contains_invalid_region_code_s_736f6724": "узел %s содержит неверный код региона %s", + "node_s_failed_to_validate_ip_group_d_ce8c1581": "Узел %s: не удалось проверить IP-группу %d: %w", + "node_s_has_no_path_to_a_terminal_8286bb22": "узел %s не имеет пути к конечному узлу", + "node_s_has_unknown_type_s_f090b1d7": "узел %s имеет неизвестный тип %s", + "node_s_is_not_reachable_from_the_start_5eacc4af": "узел %s недоступен из начального узла", + "node_s_must_have_at_least_one_incoming_7c8aac64": "узел %s должен иметь хотя бы одно входящее ребро", + "node_s_must_not_contain_more_than_d_404db8ac": "узел %s не должен содержать более %d пользовательских User-Agent шаблонов", + "node_s_output_s_is_connected_to_multiple_05e9cddf": "узел %s, выход %s подключен к нескольким целям", + "node_s_output_s_is_not_connected_6de47feb": "узел %s выход %s не подключен", + "node_s_references_an_invalid_ip_group_id_27bdf145": "Узел %s ссылается на некорректный ID IP-группы", + "node_s_references_missing_ip_group_d_40d3c647": "Узел %s ссылается на отсутствующую IP-группу %d", + "node_s_requires_at_least_one_pattern_when_480bade2": "Узел %s: укажите хотя бы один шаблон для блокировки User-Agent", + "node_was_not_found_7b451929": "узел не найден", + "nonce_mismatch_possible_replay_attack_8b3e6356": "несоответствие nonce; возможная атака повтора", + "none_484d5561": "Нет", + "not_found_4e51b67d": "не найден", + "not_signed_in_c1f65837": "Не авторизован", + "notification_channel_test_245d0001": "Тест канала уведомлений", + "notification_failed_v_263fc07a": "Уведомление не удалось: %v", + "notification_sent_s_s_e52650c7": "Уведомление отправлено: [%s] -> %s", + "notification_sent_title_s_content_summary_s_097becda": "Уведомление отправлено (название: %s, краткое содержание: %s)", + "observability_replay_minutes_must_be_greater_than_0_fc964575": "observability_replay_minutes должно быть больше 0", + "oidc_authentication_sources_require_a_discovery_url_566d40ba": "Для источников аутентификации OIDC требуется Discovery URL", + "only_administrators_can_create_tokens_with_administrator_privileges_b2e4ad01": "Только администраторы могут создавать токены с правами администратора.", + "only_automatic_and_subscription_ip_groups_support_synchronization_b7d7995c": "синхронизацию поддерживают только автоматические группы и группы подписки IP", + "only_failed_tasks_464688af": "Только задачи с ошибкой", + "only_failed_tasks_can_be_retried_279c69a0": "Повторный запуск доступен только для задач с ошибкой", + "only_failed_tasks_can_be_retried_current_status_9346f42a": "повторять можно только невыполненные задачи; текущий статус: %s", + "only_jpg_png_and_webp_images_are_supported_c7c3c323": "поддерживаются только изображения JPG, PNG и WEBP", + "openflare_automatic_ssl_renewal_cf123a50": "Автоматическое продление сертификатов SSL OpenFlare", + "openflare_pages_deployment_source_operation_866f0285": "Обновление сайта Pages из источника", + "openflare_pages_deployment_source_scan_6932f24c": "Проверка источников развёртывания Pages", + "openflare_single_certificate_ssl_renewal_a531316e": "Продление выбранного сертификата SSL OpenFlare", + "openflare_uptime_kuma_synchronization_b2168d61": "Синхронизация OpenFlare с Uptime Kuma", + "openflare_waf_ip_group_synchronization_f7213a53": "Синхронизация IP-групп WAF OpenFlare", + "openresty_observability_port_must_be_between_1_and_69074f9c": "openresty_observability_port должно быть от 1 до 65535.", + "openrestymainconfigtemplate_must_not_be_empty_3ac2d43c": "OpenRestyMainConfigTemplate не должно быть пустым.", + "openrestymainconfigtemplate_must_retain_placeholder_s_cfc00d9c": "OpenRestyMainConfigTemplate должен сохранять заполнитель %s", + "operation_not_authorized_invalid_agent_token_307a5b9a": "Операция не авторизована: недействительный токен агента", + "operation_not_authorized_invalid_registration_token_a1264630": "Операция не авторизована: неверный токен регистрации.", + "operation_not_authorized_invalid_tunnel_token_641a1499": "Операция не авторизована: неверный токен туннеля", + "operation_not_permitted_286a8d3f": "Операция не разрешена", + "origin_address_already_exists_0013db55": "исходный адрес уже существует", + "origin_address_is_missing_a_port_7b7c2ebf": "В исходном адресе отсутствует порт", + "origin_address_must_not_be_empty_f49f8b30": "адрес происхождения не должен быть пустым", + "origin_path_must_not_contain_a_protocol_8e0af122": "исходный путь не должен содержать протокол", + "origin_path_must_start_with_or_4bf64d2c": "Исходный путь должен начинаться с / или ?", + "origin_protocol_must_be_http_or_https_ab499f2a": "протокол происхождения должен быть http или https", + "origin_was_not_found_0752eb2d": "Сервер-источник не найден", + "other_d2909f16": "Прочее", + "pages_archives_support_zip_tar_gz_tar_xz_83bca097": "Архивы страниц поддерживают только форматы zip, tar.gz, tar.xz, tar.bz2, tar и 7z.", + "pages_deployment_archive_hash_is_missing_4d740a0d": "хеш архива развертывания страниц отсутствует", + "pages_deployment_archive_is_not_a_valid_compressed_9b2cefd1": "Архив развертывания страниц не является допустимым сжатым архивом", + "pages_deployment_archive_is_not_a_valid_zip_0dd602e6": "Архив развертывания страниц не является допустимым файлом ZIP.", + "pages_deployment_archive_must_be_a_zip_file_ab5b4552": "Архив развертывания страниц должен представлять собой ZIP-файл.", + "pages_deployment_archive_must_not_be_empty_ff4d94e4": "архив развертывания страниц не должен быть пустым", + "pages_deployment_archive_must_not_exceed_d_mib_3c1a0b6a": "Архив развертывания страниц не должен превышать %d МиБ", + "pages_deployment_archive_path_is_empty_87435085": "Путь к архиву развертывания страниц пуст.", + "pages_deployment_archive_upload_record_was_not_found_9990e33d": "запись загрузки архива развертывания страниц не найдена", + "pages_deployment_archive_upload_type_mismatch_s_cd5886f7": "Несоответствие типа загрузки архива развертывания страниц: %s", + "pages_deployment_archive_was_not_found_w_39714023": "Архив развертывания страниц не найден: %w", + "pages_deployment_does_not_belong_to_this_project_374852db": "развертывание страниц не принадлежит этому проекту", + "pages_deployment_is_not_included_in_the_active_26b4dcae": "развертывание страниц не включено в активную конфигурацию", + "pages_deployment_must_not_contain_more_than_d_80d2f448": "развертывание страниц не должно содержать более %d файлов", + "pages_deployment_source_configuration_changed_task_skipped_ed018def": "Изменена конфигурация источника развертывания страниц; задача пропущена", + "pages_deployment_source_operation_failed_please_try_again_19363b45": "Не удалось выполнить операцию источника развертывания страниц; пожалуйста, попробуйте еще раз позже", + "pages_deployment_source_synchronization_failed_86c51b71": "не удалось синхронизировать источник развертывания страниц", + "pages_deployment_source_synchronized_and_published_successfully_0bf1e5dd": "Источник развертывания страниц успешно синхронизирован и опубликован.", + "pages_deployment_source_task_ownership_is_no_longer_35aa212a": "Владение исходной задачей развертывания страниц больше не действительно", + "pages_deployment_source_was_not_found_c9af468e": "источник развертывания страниц не найден", + "pages_deployment_was_not_found_e90bdf07": "развертывание страниц не найдено", + "pages_entry_file_must_not_exceed_512_characters_3e7fb206": "длина файла ввода страниц не должна превышать 512 символов", + "pages_project_has_no_active_deployment_6d7971f7": "В проекте страниц нет активного развертывания", + "pages_project_is_disabled_98847c64": "проект страниц отключен", + "pages_project_is_referenced_by_rules_and_cannot_416af718": "На проект страниц ссылаются правила, и его нельзя удалить.", + "pages_project_name_must_not_be_empty_717063ef": "Название проекта страниц не должно быть пустым.", + "pages_project_or_deployment_is_empty_ddcf5875": "проект или развертывание страниц пусто", + "pages_project_slug_already_exists_b7c6e058": "Маркировка проекта страниц уже существует", + "pages_project_slug_may_only_contain_lowercase_letters_56675232": "Слаг проекта страниц может содержать только строчные буквы, цифры и дефисы.", + "pages_project_was_not_found_b6f46c90": "проект страниц не найден", + "pages_root_directory_must_not_exceed_512_characters_29d92328": "Корневой каталог страниц не должен превышать 512 символов", + "pages_source_content_is_unchanged_existing_deployment_reactivated_1441d50c": "Исходное содержимое страниц не изменилось; существующее развертывание возобновлено", + "pages_source_scan_completed_recovered_d_leases_compensated_de889c15": "Сканирование исходного кода страниц завершено: восстановлено %d арендовано, компенсировано %d потерянные записи, проверено %d источники, отправлено %d автоматические обновления, невыполненная работа %d", + "parse_fbff7b50": "Разобрать", + "parse_task_parameters_w_21ea4f33": "параметры задачи синтаксического анализа: %w", + "password_changed_successfully_c5299b82": "Пароль успешно изменен", + "password_encryption_failed_please_try_again_ef84e8d4": "шифрование пароля не удалось; пожалуйста, попробуйте еще раз", + "password_login_is_disabled_by_the_administrator_54c5d88a": "Вход по паролю отключен администратором", + "password_must_contain_at_least_8_characters_ab81f8d4": "пароль должен содержать не менее 8 символов", + "password_reset_successfully_bf63ac4e": "Пароль успешно сброшен", + "periodically_remove_unused_uploads_historical_notifications_and_expired_68ae5d13": "Периодическое удаление неиспользуемых загрузок, старых уведомлений и устаревших записей выполнения задач.", + "pg_dump_is_unavailable_install_the_postgresql_client_759ad5ae": "pg_dump недоступен; установите клиентские инструменты PostgreSQL на сервер", + "please_sign_in_first_48144f96": "Пожалуйста, сначала войдите в систему", + "port_must_not_be_empty_e53d74a2": "порт не должен быть пустым", + "postgresql_is_disabled_and_sqlite_is_the_primary_3234d0c2": "PostgreSQL отключена, а SQLite является основной базой данных; миграция на PostgreSQL недоступна", + "postgresql_is_the_primary_database_sqlite_cannot_be_4d638671": "PostgreSQL — основная база данных; SQLite нельзя выбрать для хранения журнала", + "pow_algorithm_for_node_s_must_be_fast_97989167": "Алгоритм PoW узла %s должен быть fast или slow", + "pow_challenge_ttl_for_node_s_must_be_87482278": "Вызов PoW TTL для узла %s должен длиться не менее 30 секунд.", + "pow_difficulty_for_node_s_must_be_between_4eaec0b8": "Сложность PoW для узла %s должна быть от 1 до 16.", + "pow_session_ttl_for_node_s_must_be_98ed3573": "Сеанс PoW TTL для узла %s должен длиться не менее 60 секунд.", + "precreated_target_postgresql_partitions_from_s_to_s_0bc5358f": "Предварительно созданные целевые разделы PostgreSQL от %s до %s", + "preparing_group_synchronization_group_s_id_d_enabled_9d8361f3": "Подготовка групповой синхронизации: group=%s id=%d Enabled=%v target=%s default_proxied=%v", + "preparing_node_synchronization_node_s_id_d_ip_61c8f04a": "Подготовка синхронизации узла: node=%s id=%d ip=%s", + "preparing_to_issue_or_renew_a_certificate_for_18326882": "Подготовка к выдаче или продлению сертификата для доменов [%s]", + "preparing_to_send_email_to_s_subject_s_1f6fad47": "Готовимся отправить электронное письмо на адрес: %s, тема: %s", + "previous_pages_deployment_source_task_lease_expired_4de3ac84": "Срок блокировки задачи источника развертывания предыдущих страниц истек", + "primary_and_standby_nodes_must_be_different_7f96a264": "первичный и резервный узлы должны быть разными", + "provide_the_remote_deployment_archive_url_887e4ef2": "предоставьте архив удаленного развертывания URL", + "querying_next_migration_batch_migrated_d_d_c481c987": "Запрос следующего пакета миграции; перенесено: %d/%d", + "rebuild_file_storage_statistics_fe52d825": "Пересчёт статистики файлового хранилища", + "rebuild_w_upload_stats_from_active_w_uploads_08023859": "Пересчёт w_upload_stats по активным записям w_uploads: общие показатели, типы, категории и динамика.", + "rebuilding_file_storage_statistics_active_records_d_60a4199b": "Восстановление статистики хранения файлов; активные записи: %d", + "receiving_this_message_confirms_that_the_notification_channel_79c41407": "Получение этого сообщения подтверждает, что проверка подключения канала уведомлений прошла успешно.", + "recipient_b013dc37": "Получатель", + "recipient_email_to_88e59c15": "Адрес электронной почты получателя (Кому)", + "record_was_not_found_e4ba0386": "Запись не найдена", + "recover_recovered_expired_source_lease_source_id_d_c4a7c01f": "[восстановить] Восстановлена истекшая аренда исходного кода: source_id=%d", + "registration_failed_please_try_again_later_a4807a5d": "регистрация не удалась; пожалуйста, попробуйте еще раз позже", + "registration_is_disabled_by_the_administrator_420d53e6": "Регистрация отключена администратором", + "remote_addr_must_not_be_empty_ba45da61": "remote_addr не должно быть пустым", + "remote_source_redirect_limit_exceeded_3fdd16bf": "Превышен лимит перенаправления удаленного источника", + "remote_url_sources_do_not_support_update_checks_1ecca79c": "удаленные источники URL не поддерживают проверку обновлений; использовать синхронизацию сейчас", + "remote_url_sources_must_not_include_github_or_48c0b153": "удаленные источники URL не должны включать GitHub или конфигурацию автоматического обновления.", + "renew_a_single_specified_acme_certificate_a27fd4e1": "Продлить один указанный сертификат ACME.", + "renewal_certificate_data_saved_9d25aae9": "[Продление] Данные сертификата сохранены.", + "renewal_certificate_issued_securely_storing_certificate_and_private_cfaa1b37": "[Продление] Выдан сертификат; безопасное хранение сертификата и закрытого ключа в базе данных...", + "renewal_decrypting_dns_account_credentials_and_acme_account_6ac3acd9": "[Продление] Расшифровка учетных данных учетной записи DNS и закрытого ключа учетной записи ACME...", + "renewal_requested_domains_v_e4bf6bab": "[Продление] Запрошенные домены: %v", + "renewal_requesting_ssl_certificate_issuance_through_the_acme_98e351ee": "[Продление] Запрос на выдачу сертификата SSL через вызов ACME DNS-01; пожалуйста, подождите...", + "renewal_resolving_acme_account_1bc0d71a": "[Продление] Разрешение учетной записи ACME...", + "renewal_resolving_dns_account_information_id_d_89687757": "[Продление] Разрешение информации об учетной записи DNS (ID=%d)...", + "renewal_saving_acme_account_changes_15275665": "[Продление] Сохранение ACME изменений учетной записи...", + "renewal_starting_renewal_setting_application_status_to_applying_a167afb3": "[Продление] Начало продления; установка статуса заявки на подачу заявки...", + "request_body_json_77cfb393": "Тело запроса (JSON)", + "request_url_65a1f546": "URL запроса", + "reset_the_password_for_a_specified_account_ef0bf7e5": "Сбросить пароль для указанной учетной записи", + "resolve_acquiring_source_task_ownership_8bb0e8c7": "[решить] Получение права владения исходной задачей", + "resolve_another_source_task_is_running_skipping_task_a4e1db69": "[решить] Выполняется другая исходная задача; пропуск задачи", + "resolve_resolving_github_release_repo_s_asset_s_6915eea4": "[поиск] Поиск релиза GitHub: репозиторий=%s файл=%s", + "resolve_source_configuration_changed_skipping_task_4b3e6282": "[решить] Исходная конфигурация изменена; пропуск задачи", + "resolve_source_configuration_or_ownership_changed_skipping_task_d8b6d56a": "[решить] Исходная конфигурация или право собственности изменены; пропуск задачи", + "resolve_source_no_longer_exists_skipping_task_c4e25edd": "[решить] Источник больше не существует; пропуск задачи", + "result_must_be_success_warning_or_failed_7bca2806": "результат должен быть успешным, предупреждением или неудачным", + "retention_days_must_be_between_1_and_3650_3b6a649b": "retention_days должно быть между 1 и 3650.", + "retention_days_must_be_between_1_and_90_371a59a7": "retention_days должно быть от 1 до 90", + "root_domain_still_contains_domains_delete_them_all_277bc1d6": "корневой домен все еще содержит домены; сначала удали их все", + "route_configuration_path_must_not_be_empty_f70cb124": "путь конфигурации маршрута не должен быть пустым", + "route_s_w_0efcb499": "маршрут %s %w", + "rule_graph_contains_multiple_start_nodes_e13cd7e2": "Граф правил содержит несколько начальных узлов", + "rule_graph_is_missing_a_start_node_1efe3116": "в графе правил отсутствует начальный узел", + "rule_graph_must_contain_exactly_one_allow_node_a05ca0b3": "Граф правил должен содержать ровно один разрешающий узел", + "rule_graph_must_contain_exactly_one_start_node_3140d98e": "Граф правил должен содержать ровно один начальный узел", + "rule_graph_must_not_contain_cycles_fd6fe797": "граф правил не должен содержать циклов", + "rule_graph_must_not_contain_more_than_d_3289a255": "Граф правил не должен содержать более %d ребер", + "rule_graph_must_not_contain_more_than_d_76688447": "Граф правил не должен содержать более %d узлов", + "rule_graph_must_not_exceed_256_kib_855948bb": "Граф правил не должен превышать 256 КиБ", + "rule_graph_schema_version_must_be_d_38d452c4": "график правил schema_version должен быть %d", + "rule_was_updated_by_another_operation_reload_it_bba246a7": "Правило было обновлено другой операцией; перезагрузи его", + "s_contains_an_empty_domain_name_e4a83686": "%s содержит пустое доменное имя", + "s_contains_duplicate_domain_name_s_15451d4d": "%s содержит повторяющееся доменное имя %s", + "s_contains_invalid_characters_enter_valid_ip_addresses_59634931": "%s содержит недопустимые символы; введите действительные адреса IP или имена доменов, разделенные пробелами.", + "s_contains_invalid_network_range_q_ce167d93": "%s содержит недопустимый сетевой диапазон %q", + "s_contains_unsupported_value_q_6208a8c5": "%s содержит неподдерживаемое значение %q", + "s_domain_name_must_be_lowercase_without_surrounding_59ed3d2c": "%s доменное имя должно быть в нижнем регистре без пробелов вокруг: %s", + "s_is_not_a_valid_ip_address_643806a5": "%s не является действительным адресом IP", + "s_is_not_a_valid_ip_range_4835d8bf": "%s не является допустимым диапазоном IP", + "s_must_be_a_duration_with_a_unit_b97fceb4": "%s: укажите длительность с единицей измерения, например 30m или 5s", + "s_must_be_a_json_array_of_strings_dbcb2bd7": "%s должен быть массивом строк JSON.", + "s_must_be_a_nonnegative_integer_0_means_efc94604": "%s должно быть неотрицательным целым числом (0 означает неограниченное количество)", + "s_must_be_a_nonnegative_integer_0bbc7814": "%s должно быть неотрицательным целым числом.", + "s_must_be_a_whole_number_of_days_c0ac3325": "%s должно быть целым числом дней, большим или равным 1.", + "s_must_be_an_integer_between_1_and_3f9a159c": "%s должно быть целым числом от 1 до 2048 (МиБ).", + "s_must_be_an_integer_greater_than_0_9553dcdf": "%s должно быть целым числом больше 0", + "s_must_be_an_integer_or_a_size_2e9fba45": "%s должно быть целым числом или размером с суффиксом k/m/g.", + "s_must_be_between_100_and_999_6b9e594f": "%s должно быть между 100 и 999.", + "s_must_be_disabled_mmdb_ip_api_geojs_deba53da": "%s: допустимые значения — disabled, mmdb, ip-api, geojs или ipinfo", + "s_must_be_epoll_kqueue_poll_select_rtsig_3177cbf8": "%s должно быть epoll, kqueue, poll, select, rtsig, /dev/poll, eventport или пустое.", + "s_must_be_true_or_false_3a8153ff": "%s: допустимы только true или false", + "s_must_contain_at_least_one_status_code_15395d65": "%s должен содержать хотя бы один тег кода состояния.", + "s_must_contain_at_most_d_network_ranges_6b0b78c4": "%s должно содержать не более %d сетевых диапазонов", + "s_must_not_be_empty_2d5ac8f7": "%s не должно быть пустым", + "s_must_not_be_empty_after_expansion_e814e9ef": "%s не должно быть пустым после раскрытия", + "s_must_not_contain_newlines_54742aef": "%s не должен содержать символы новой строки", + "s_must_not_contain_newlines_or_tabs_ff4de73f": "%s не должно содержать символов новой строки или табуляции.", + "s_must_not_exceed_d_4b586e57": "%s не должно превышать %d", + "s_must_not_exceed_d_bytes_256_kib_2f297bdb": "%s не должно превышать %d байт (256 КиБ)", + "s_must_use_a_format_such_as_16_1042e770": "%s должен использовать такой формат, как «16 16k».", + "s_must_use_a_format_such_as_1_5a9d67e2": "%s должен использовать такой формат, как «1:2» или «1:2:2».", + "s_supports_at_most_d_domain_names_4f3d5128": "%s поддерживает не более %d доменных имен.", + "scan_acme_certificates_nearing_expiration_and_renew_them_c53289c4": "Проверка и автоматическое продление сертификатов ACME с истекающим сроком действия.", + "scan_due_sources_d_batch_d_ab93c95c": "[сканировать] Источники = %d, пакет = %d", + "scan_sources_still_due_after_this_batch_d_da73edba": "[сканирование] Исходники еще не получены после этой партии=%d", + "scanning_certificates_due_for_renewal_7f9e9ca0": "Сканирование сертификатов, подлежащих продлению", + "scanning_unused_uploads_cutoff_s_5c2fbb16": "Сканирование неиспользуемых загрузок, ограничение: %s", + "schedule_system_cleanup": "Очистка системы по расписанию", + "schedule_was_not_found_6205508a": "Расписание не найдено", + "seconds_00fe8dda": " секунды", + "select_a_cname_a_or_aaaa_record_type_80ac9137": "выберите тип записи CNAME, A или AAAA", + "select_a_file_to_upload_a3a97de2": "Выберите файл для загрузки", + "select_a_pages_deployment_source_type_70065e2c": "выберите тип источника развертывания Pages", + "select_a_valid_cloudflare_dns_account_97385483": "выберите действительную учетную запись Cloudflare DNS", + "select_a_valid_edge_node_0fafe682": "Выберите доступный пограничный узел", + "select_a_valid_target_mode_9a0c6691": "выберите действительный целевой режим", + "selected_certificate_was_not_found_3772e60e": "выбранный сертификат не найден", + "send_data_to_an_external_webhook_using_a_068df6b3": "Отправьте данные на внешний вебхук, используя специальный запрос HTTP POST.", + "send_email_c268c1b1": "Отправить письмо", + "send_failed_v_a04ce62f": "Не удалось отправить: %v", + "send_notification_e70e3a00": "Отправить уведомление", + "send_system_email_asynchronously_63d68e86": "Отправка системных писем в фоновом режиме.", + "sequentially_convert_managed_images_to_webp_at_the_05d39459": "Последовательное преобразование изображений в WebP с заданным качеством и сохранение в постоянном кэше.", + "serialize_289c64ad": "Сериализация", + "server_address_server_address_is_not_configured_configure_9e094296": "адрес сервера (server_address) не настроен; настройте его в настройках системы и попробуйте еще раз", + "server_url_must_not_be_empty_269fe8ad": "server_url не должно быть пустым", + "signature_verification_key_from_the_feishu_group_bot_6090b176": "Ключ проверки подписи из настроек безопасности бота группы Feishu", + "signing_secret_optional_9dcdda03": "Секрет подписи (необязательно)", + "since_and_until_must_be_provided_together_4fdc9431": "с и до должны быть предоставлены вместе", + "since_must_be_an_rfc3339_timestamp_ad87e410": "поскольку это должна быть временная метка RFC3339", + "source_database_contains_no_logs_skipping_partition_precreation_c2753081": "Исходная база данных не содержит журналов; пропуск предварительного создания раздела", + "spa_fallback_path_contains_unsupported_characters_14eecbe2": "Резервный путь SPA содержит неподдерживаемые символы", + "spa_fallback_path_must_not_contain_or_segments_eee02366": "SPA резервный путь не должен содержать . или .. сегменты", + "spa_fallback_path_must_not_contain_whitespace_or_a7661241": "SPA резервный путь не должен содержать пробелов или управляющих символов.", + "spa_fallback_path_must_not_exceed_512_characters_8be1ecf7": "SPA запасной путь не должен превышать 512 символов.", + "spa_fallback_path_must_point_to_a_specific_dff5905a": "Резервный путь SPA должен указывать на конкретный файл", + "spa_fallback_path_must_start_with_91d66834": "Резервный путь SPA должен начинаться с /", + "specified_version_was_not_found_s_eeec8487": "указанная версия не найдена: %s", + "sql_statement_must_not_be_empty_c3a52981": "Оператор SQL не должен быть пустым.", + "start_api_worker_and_scheduler_together_3e558997": "Запуск API, Worker и Scheduler вместе", + "start_node_s_must_not_have_incoming_edges_0501404b": "начальный узел %s не должен иметь входящих ребер", + "starting_certificate_renewal_id_d_034783ec": "Начало продления сертификата, ID: %d", + "starting_domain_synchronization_domain_s_zone_s_group_49f9cc95": "Начинаем синхронизацию домена: домен=%s зона=%s группа=%s(#%d) target=%s прокси=%v member_id=%d", + "starting_log_database_migration_s_s_6a587927": "Запуск миграции базы данных журналов: %s -> %s", + "starting_notification_event_s_channel_s_recipient_s_561117aa": "Начало уведомления: событие = %s, канал = %s, получатель = %s", + "starting_s_synchronization_name_s_id_d_target_ce5444ff": "Начинаем синхронизацию %s: name=%s id=%d target=%s domains=%d", + "starting_sequential_compressed_image_cache_warmup_quality_s_ec690c4f": "Запускаем последовательную прогрев кэша сжатых изображений, качество: %s, размер пакета: %d", + "starting_storage_migration_s_s_total_objects_d_43cbe32d": "Запуск миграции хранилища: %s -> %s, общее количество объектов: %d", + "status_code_must_be_an_integer_between_100_3328ab45": "status_code должно быть целым числом от 100 до 599.", + "status_code_must_be_between_d_and_d_f8229da8": "код состояния должен быть между %d и %d: %s", + "status_code_range_endpoints_are_reversed_s_afcfd994": "Конечные точки диапазона кода состояния поменялись местами: %s", + "status_code_tag_must_not_be_empty_21235ebf": "тег кода статуса не должен быть пустым", + "storage_configuration_updated_active_storage_remains_s_917066ca": "Конфигурация хранилища обновлена; остается активная память %s", + "storage_configuration_updated_directly_failed_migration_task_marked_c9d7148d": "Конфигурация хранилища обновляется напрямую; неудачная задача миграции помечена как решенная", + "storage_connectivity_4038ce91": "Возможность подключения хранилища", + "storage_connectivity_test_failed_w_08dabbf5": "Проверка подключения к хранилищу не удалась: %w", + "storage_migration_completed_migrated_d_objects_active_storage_332fb1de": "Миграция хранилища завершена; перенесены объекты %d, активное хранилище переключено на %s", + "storage_migration_maintenance_is_active_files_are_read_4bf9e57d": "поддержка миграции хранилища активна; файлы доступны только для чтения", + "subject_line_of_the_email_to_send_c3aa0753": "Тема письма для отправки", + "subscription_content_must_not_exceed_d_bytes_f40541e2": "содержимое подписки не должно превышать %d байт", + "subscription_returned_status_code_d_2c185ec6": "подписка вернула код состояния %d", + "subscription_url_must_use_http_or_https_e43cbb99": "подписка URL должна использовать HTTP или HTTPS", + "succeeded_053461ce": "Успешно", + "switch_group_primary_and_standby_nodes_based_on_b2d78b3f": "Основные и резервные узлы группы коммутации в зависимости от доступности узла и состояния конфигурации.", + "switch_log_database_da446b57": "Переключение базы данных журналов", + "synchronization_completed_d_ip_addresses_or_ranges_7b645ca2": "Синхронизация завершена; %d IP адреса или диапазоны", + "synchronization_succeeded_bca900e9": "Синхронизация прошла успешно", + "synchronize_all_domains_currently_targeting_the_specified_node_3488196a": "Синхронизировать все домены, которые в данный момент нацелены на указанный узел.", + "synchronize_all_domains_in_a_target_group_b2397009": "Синхронизировать все домены в целевой группе", + "synchronize_automatic_and_subscription_waf_ip_groups_that_ac3d686e": "Обновление автоматических IP-групп WAF и IP-групп по подписке, для которых наступило время синхронизации.", + "synchronize_cloudflare_dns_records_for_a_single_domain_a06ce84b": "Синхронизировать записи Cloudflare DNS для одного домена.", + "synchronize_enabled_proxy_routes_with_uptime_kuma_monitors_8f761ebe": "Синхронизация активных маршрутов прокси с мониторами Uptime Kuma.", + "synchronizing_proxy_routes_with_uptime_kuma_2d26b523": "Синхронизация прокси-маршрутов с Uptime Kuma", + "synchronizing_waf_ip_groups_due_for_an_update_e96ea0b3": "Синхронизация групп WAF IP, подлежащих обновлению", + "system_5b50d7c4": "Система", + "system_channel_test_415dd03a": "Тест системного канала", + "system_cleanup_6c08e88d": "Очистка системы", + "system_cleanup_completed_removed_d_d_unused_uploads_5eca4159": "Очистка системы завершена. Удалены неиспользуемые загрузки %d/%d, записи аудита уведомлений %d и журналы выполнения задач %d.", + "system_configuration_was_not_found_00210cae": "Конфигурация системы не найдена", + "system_manual_retry_created_and_enqueued_original_task_3c9cbd5d": "[Система] Создана и поставлена в очередь повторная попытка вручную (исходная задача ID: %s, повторная попытка: %d/%d)", + "system_notification_94b7513e": "Системное уведомление", + "system_reserved_file_types_cannot_be_managed_through_9369ffe7": "Типы файлов, зарезервированные системой, не могут управляться через общий файл API", + "system_smtp_configuration_is_incomplete_291bb369": "конфигурация системы SMTP неполная", + "system_starting_asynchronous_task_name_s_type_s_90c6e5e2": "[Система] Запуск асинхронной задачи [имя: %s, тип: %s], повтор: %d/%d", + "system_starting_asynchronous_task_type_s_caca3e5b": "[Система] Запуск асинхронной задачи [тип: %s]", + "system_task_enqueued_waiting_for_execution_queue_s_86a50bcc": "[Система] Задача поставлена в очередь; ожидание выполнения (очередь: %s, максимальное количество повторов: %d)", + "system_task_failed_duration_d_ms_error_v_63653548": "[Система] Задача не выполнена; продолжительность: %d мс, ошибка: %v", + "system_task_succeeded_duration_d_ms_result_s_161aa7ca": "[Система] Задача выполнена успешно; продолжительность: %d мс, результат: %s", + "target_group_is_disabled_08c76979": "целевая группа отключена", + "target_group_must_differ_from_the_current_group_2702a3d3": "целевая группа должна отличаться от текущей группы", + "target_group_was_not_found_71c24083": "целевая группа не найдена", + "target_log_database_febc306d": "Целевая база журналов", + "target_log_database_is_already_active_no_migration_92ab4313": "целевая база данных журналов уже активна; миграция не требуется", + "target_storage_configuration_json_884c1a08": "Конфигурация целевого хранилища (JSON)", + "task_cannot_continue_53c9c59a": "Задача не может быть продолжена", + "task_completed_0b4c0784": "Задача выполнена: ", + "task_execution_was_not_found_fc632d27": "Запись о выполнении задачи не найдена", + "task_execution_was_not_found_w_284e6bfc": "выполнение задачи не найдено: %w", + "task_parameters_must_not_be_empty_bdc11114": "параметры задачи не должны быть пустыми", + "telegram_bot_b0edcbcc": "Бот Telegram", + "template_content_must_not_be_empty_6fce7653": "Содержимое шаблона не должно быть пустым.", + "template_identifier_already_exists_93895c12": "идентификатор шаблона уже существует", + "template_identifier_must_not_be_empty_7a1e41d3": "Идентификатор шаблона не должен быть пустым.", + "template_name_must_not_be_empty_da00e8dc": "Имя шаблона не должно быть пустым.", + "template_s_was_not_found_or_is_unavailable_70f4fa4e": "шаблон %s не найден или недоступен: %w", + "template_was_not_found_37b0cea2": "Шаблон не найден", + "terminal_node_s_must_not_have_outgoing_edges_8ece7ac6": "терминальный узел %s не должен иметь исходящих ребер", + "the_active_node_requires_a_valid_ipv4_address_edf71cfe": "активному узлу требуется действительный адрес IPv4", + "the_active_pages_deployment_cannot_be_deleted_9f0bb393": "активное развертывание Pages невозможно удалить", + "the_confirmed_version_has_changed_refresh_and_confirm_8a40d0fc": "подтвержденная версия изменилась; обновите и подтвердите еще раз", + "the_current_rules_have_not_changed_and_cannot_c8b88f7d": "действующие правила не изменились и не могут быть опубликованы снова", + "the_currently_signed_in_user_cannot_be_deleted_e47df151": "текущий вошедший в систему пользователь не может быть удален", + "the_log_database_is_clickhouse_but_clickhouse_is_bc5e49a0": "база данных журналов — ClickHouse, но ClickHouse отключена. Снова включите ClickHouse и запустите сервер, затем запустите задачу Переключить базу данных журнала для перехода на PostgreSQL/SQLite перед отключением ClickHouse", + "the_log_database_is_postgresql_but_postgresql_is_01f3b8d3": "база данных журналов — PostgreSQL, но PostgreSQL отключена, а SQLite является основной базой данных. Запустите задачу переключения базы данных журнала для перехода на SQLite или включите PostgreSQL.", + "the_log_database_is_sqlite_but_postgresql_is_8cfdfdaa": "база данных журналов — SQLite, но PostgreSQL является основной базой данных. Запустите задачу базы данных журнала переключения, чтобы перейти на PostgreSQL.", + "the_selected_version_is_not_a_preview_release_15d2f434": "выбранная версия не является предварительной версией", + "the_specified_entry_file_does_not_exist_in_f029c906": "Указанный файл записи не существует в активном развертывании.", + "the_specified_uploaded_asset_was_not_found_in_942c8d4d": "указанный загруженный ресурс не был найден в версии GitHub", + "this_access_token_has_no_administrator_privileges_and_9005f2b0": "Этот токен доступа не имеет прав администратора и не может получить доступ к конечным точкам администратора.", + "this_account_has_no_email_address_contact_an_4e504171": "У этой учетной записи нет адреса электронной почты; свяжитесь с администратором, чтобы привязать адрес электронной почты перед входом в систему", + "this_account_has_no_email_address_use_a_e29bb819": "У этой учетной записи нет адреса электронной почты; используйте временный код для входа", + "this_configuration_is_managed_by_system_tasks_and_1930e327": "эта конфигурация управляется системными задачами и не может быть изменена вручную", + "this_dns_account_is_used_by_certificates_and_54842b64": "эта учетная запись DNS используется сертификатами и не может быть удалена", + "this_domain_already_belongs_to_another_target_group_1f6c5889": "этот домен уже принадлежит другой целевой группе", + "this_email_address_is_bound_to_another_account_48d6a2fa": "этот адрес электронной почты привязан к другой учетной записи", + "this_endpoint_does_not_allow_access_token_authentication_93ced189": "Эта конечная точка не разрешает аутентификацию по токену доступа.", + "this_external_account_is_bound_to_another_user_029fce16": "эта внешняя учетная запись привязана к другому пользователю", + "this_is_a_system_notification_channel_connectivity_test_cbd9d668": "Это проверка подключения канала уведомлений системы.", + "this_node_is_not_a_tunnelclient_0a666f7a": "Этот узел не является TunnelClient", + "this_node_is_not_a_tunnelrelay_49925484": "Этот узел не является TunnelRelay", + "this_origin_is_still_referenced_by_rules_and_fad589f1": "этот источник все еще упоминается в правилах и не может быть удален", + "this_task_cannot_be_retried_be95f1db": "эту задачу нельзя повторить", + "time_range_must_not_exceed_30_days_93f190e7": "временной диапазон не должен превышать 30 дней", + "to_subject_and_body_must_not_be_empty_614c8b17": "to, тема и тело не должны быть пустыми", + "token_name_must_not_be_empty_cd7ba9f4": "Имя токена не должно быть пустым.", + "token_was_not_found_or_access_is_not_b430e116": "токен не найден или доступ запрещен", + "too_many_login_attempts_try_again_later_3245d6d9": "Слишком много попыток входа в систему; попробуйте еще раз позже", + "too_many_login_requests_please_try_again_later_4bcd0be6": "слишком много запросов на вход; пожалуйста, попробуйте еще раз позже", + "transfer_uploading_file_to_target_storage_s_size_8118b17d": "[Передача] Загрузка файла в целевое хранилище: %s (размер: %d байт, тип: %s)", + "triggered_when_an_administrator_signs_in_successfully_0ff0cfc5": "Срабатывает, когда администратор успешно входит в систему", + "tunnel_token_must_not_be_empty_67a53536": "tunnel_token не должно быть пустым", + "unable_to_compile_configuration_for_node_s_w_66b38ddb": "невозможно скомпилировать конфигурацию для узла %s: %w", + "unable_to_create_database_export_file_c78edf48": "Невозможно создать файл экспорта базы данных.", + "unable_to_enqueue_cloudflare_synchronization_task_4fc208da": "невозможно поставить в очередь задачу синхронизации Cloudflare", + "unable_to_export_database_9f34252c": "Невозможно экспортировать базу данных", + "unable_to_generate_an_available_username_dec720c4": "невозможно создать доступное имя пользователя", + "unable_to_get_the_currently_active_configuration_version_6a36cf1a": "невозможно получить текущую активную версию конфигурации: %s", + "unable_to_get_the_currently_active_configuration_version_c8a24858": "невозможно получить текущую активную версию конфигурации: %w", + "unable_to_get_username_from_authentication_source_973bd1db": "невозможно получить имя пользователя из источника аутентификации", + "unable_to_identify_the_current_user_9a5f999b": "Невозможно идентифицировать текущего пользователя", + "unable_to_open_database_file_df3e8a7f": "Невозможно открыть файл базы данных: ", + "unable_to_read_database_file_information_52111f56": "Невозможно прочитать информацию о файле базы данных: ", + "unable_to_save_session_please_try_again_58acb751": "Невозможно сохранить сеанс; пожалуйста, попробуйте еще раз", + "unable_to_serialize_rule_graph_w_48c45bb9": "невозможно сериализовать график правил: %w", + "unauthorized_access_f0d65927": "Несанкционированный доступ", + "unknown_4d8c1c5b": "Неизвестно", + "unknown_log_database_configuration_s_98368584": "неизвестная конфигурация базы данных журналов: %s", + "unknown_node_type_s_332fe973": "неизвестный тип узла %s", + "unknown_task_type_s_b04735f2": "неизвестный тип задачи: %s", + "unregistered_task_handler_s_271644ad": "незарегистрированный обработчик задач: %s", + "unsupported_pages_deployment_source_type_5b4321a7": "неподдерживаемый тип источника развертывания Pages", + "unsupported_verification_scenario_e2264302": "Неподдерживаемый сценарий проверки", + "until_must_be_an_rfc3339_timestamp_ce5dee46": "до должна быть временная метка RFC3339", + "until_must_be_later_than_since_a541175c": "до должно быть позже, чем с тех пор", + "update_archive_size_mismatch_got_d_want_d_195807e8": "несоответствие размера архива обновлений: получил %d, хочу %d", + "upstream_response_s_a987ca9f": "Ответ восходящего потока: %s", + "uptime_kuma_address_is_required_when_the_integration_2a83e4ea": "время безотказной работы Адрес Kuma требуется, если интеграция включена", + "uptime_kuma_address_must_start_with_http_or_87425cf2": "Адрес Kuma для бесперебойной работы должен начинаться с http:// или https://.", + "uptime_kuma_integration_is_disabled_skipping_execution_30faa46e": "Uptime Kuma интеграция отключена; пропуск выполнения", + "uptime_kuma_password_is_required_when_the_integration_6eed6c4c": "Пароль Kuma для бесперебойной работы требуется, если интеграция включена", + "uptime_kuma_synchronization_completed_dfb5cd80": "Синхронизация с Uptime Kuma завершена", + "uptime_kuma_synchronization_failed_v_0f0e24be": "Uptime Kuma не удалось синхронизировать: %v", + "uptime_kuma_synchronization_is_already_running_skipping_this_3e44a54f": "Uptime Kuma синхронизация уже запущена; пропускаю это расписание", + "uptime_kuma_username_is_required_when_the_integration_f43559eb": "время безотказной работы имя пользователя Kuma требуется, если интеграция включена", + "user_id_is_required_5f1165d8": "Укажите ID пользователя.", + "user_id_must_not_be_empty_07bc47a7": "пользователь ID не должен быть пустым", + "user_was_not_found_8a2ea403": "пользователь не найден", + "user_was_not_found_a82de572": "пользователь не найден", + "username_already_exists_d1ee5409": "имя пользователя уже существует", + "username_must_not_be_empty_390ccdec": "имя пользователя не должно быть пустым", + "username_must_not_be_empty_fb128ac0": "Имя пользователя не должно быть пустым.\n", + "username_of_the_account_whose_password_will_be_29c265c9": "Имя пользователя учетной записи, пароль которой будет сброшен", + "username_or_email_address_is_already_in_use_18fa1479": "имя пользователя или адрес электронной почты уже используется", + "username_s_264d6306": "Имя пользователя: %s\n", + "validate_154723a1": "Подтвердить", + "verification_checking_target_file_consistency_sha_256_s_daef3bb0": "[Проверка] Проверка целостности целевого файла (SHA-256): %s", + "verification_code_is_incorrect_or_expired_62fb7904": "код подтверждения неверен или срок его действия истек", + "verification_codes_are_being_requested_too_frequently_try_84835f1c": "коды проверки запрашиваются слишком часто; попробуйте еще раз позже", + "verified_file_consistency_check_succeeded_s_09148b16": "[Проверено] Проверка целостности файла прошла успешно: %s", + "verify_verifying_archive_structure_and_entry_file_dcc5d692": "[verify] Проверка структуры архива и входного файла", + "verify_verifying_github_release_archive_and_entry_file_a00c1f16": "[проверка] Проверка архива релиза GitHub и входного файла", + "version_identifier_conflict_please_try_again_c81c8dad": "конфликт идентификаторов версий; пожалуйста, попробуйте еще раз", + "version_must_not_be_empty_09fabcb3": "версия не должна быть пустой", + "videos_c20f7618": "Видео", + "waf_ip_group_snapshot_size_d_bytes_exceeds_4bdc932a": "WAF IP размер группового снимка %d байт превышает предел в %d байт", + "waf_ip_group_synchronization_completed_8c799689": "Синхронизация IP-групп WAF завершена", + "waf_ip_group_synchronization_failed_v_3524af2d": "WAF IP групповая синхронизация не удалась: %v", + "waf_rule_group_d_was_not_found_4e2d4084": "Группа правил WAF %d не найдена", + "waf_rule_name_must_not_be_empty_92c1a213": "Укажите название правила WAF", + "waf_rule_operation_failed_4e01c228": "Ошибка операции с правилом WAF", + "waf_rule_was_not_found_d3a18a2e": "Правило WAF не найдено", + "waiting_for_image_cache_warmup_lock_quality_s_88fe5819": "Ожидание блокировки прогрева кэша изображений, качество: %s", + "warm_up_compressed_image_cache_bd2f3c3f": "Прогрев кэша сжатых изображений", + "warning_source_file_is_missing_marking_it_deleted_552b422a": "Внимание: исходный файл отсутствует; пометить его как удаленное и пропустить: %s (ошибка: %v)", + "webhook_url_658c6265": "URL вебхука", + "webp_compression_quality_must_be_low_medium_or_866b5127": "Качество сжатия WebP: допустимы low, medium или high", + "wildcard_domain_names_are_not_supported_e40045cc": "доменные имена с подстановочными знаками не поддерживаются", + "years_cda14918": " годы ", + "zone_must_be_a_valid_registered_root_domain_cc7d1d33": "зона должна быть действительным зарегистрированным корневым доменом", + "zone_was_not_found_86db3707": "Зона не найдена", + "public_error_a7274b96774b": "Укажите название канала", + "public_error_9f24cd1da554": "Название канала может содержать только латинские буквы, цифры и символы подчёркивания", + "public_error_1b139d2dcf79": "Укажите URL или адрес запроса", + "public_error_88df46747841": "Для безопасного подключения URL запроса должен использовать HTTPS", + "public_error_29d0f4706829": "Укажите шаблон тела запроса", + "public_error_2127b11c4518": "Укажите токен бота Telegram", + "public_error_22bcab9337f1": "Шаблон тела запроса должен содержать корректный JSON", + "public_error_7c511ae7174a": "Проверка CAPTCHA не настроена", + "public_error_71759e624238": "Некорректный контекст сеанса", + "public_error_b9cbe3209e14": "Состояние OAuth не соответствует текущему сеансу", + "public_error_ff5184f57c49": "Пользователь для привязки OAuth не соответствует текущему сеансу", + "public_error_b5121d590135": "Неверный ID", + "public_error_519c7fed477a": "Неверный ID канала", + "public_error_190df01d2076": "Канал не найден", + "public_error_cbd9709fbd01": "Уведомления об этом событии уже настроены", + "public_error_b93a32137fc1": "Пользовательский шаблон должен содержать корректный JSON", + "public_error_add0341f2152": "Для включения события настройте хотя бы один канал уведомлений", + "public_error_e59e34f32d9f": "Канал с таким названием уже существует", + "public_error_cdb14a9d218e": "Неподдерживаемый тип задачи", + "public_error_02fbf8eef643": "Укажите event_key или task_type", + "public_error_7f64283d20ea": "Неподдерживаемый ключ встроенного события", + "public_error_eac68fcd302d": "Неверный ID события", + "public_error_c854c958bd9c": "Событие уведомления не найдено", + "public_error_a37526e5d726": "Недействительный ключ повторной регистрации", + "public_error_452209a49676": "Учётные данные регистрации изменены; используйте назначенный токен агента", + "public_error_da739b170c48": "Недопустимые параметры списка узлов", + "public_error_79e7e0ad56c0": "Не удалось загрузить узлы" +} diff --git a/pkg/i18n/locales/zh-CN.json b/pkg/i18n/locales/zh-CN.json new file mode 100644 index 00000000..2265c933 --- /dev/null +++ b/pkg/i18n/locales/zh-CN.json @@ -0,0 +1,990 @@ +{ + "schedule_system_cleanup": "系统定期垃圾清理", + "a_file_in_the_pages_deployment_archive_exceeds_bf65ffe2": "pages 部署包内文件过大", + "a_new_github_release_deployment_archive_is_available_3ba1aeff": "发现新的 GitHub Release 部署包", + "a_node_ip_is_required_when_ip_locking_2fe806d7": "锁定节点 IP 时必须填写节点 IP", + "a_pages_deployment_source_task_is_already_running_2c10973f": "pages 部署源任务正在执行", + "a_preview_release_cannot_be_selected_for_a_b0318833": "正式版更新不能选择 preview 发布", + "account_binding_id_must_not_be_empty_84f24a4e": "绑定记录 ID 不能为空", + "account_is_disabled_da6e1fd3": "账号已被封禁", + "activate_atomically_switching_github_release_deployment_80feec83": "[activate] 正在原子切换 GitHub Release 部署", + "activate_atomically_switching_production_deployment_80300199": "[activate] 正在原子切换生产部署", + "activate_source_configuration_or_ownership_changed_deployment_was_e21f8288": "[activate] 来源配置或执行权已变化,本次任务未切换部署", + "administrator_accounts_cannot_be_deleted_f3ae74a2": "不能删除管理员用户", + "administrator_accounts_cannot_be_disabled_d3eef776": "不能禁用管理员用户", + "administrator_login_c454c61e": "管理员登录", + "administrator_login_notification_227c9f47": "管理员登录提醒", + "administrator_privileges_cannot_be_revoked_from_the_currently_75858bcb": "不能撤销当前登录用户的管理员权限", + "administrator_user_username_signed_in_at_time_from_a2edfba5": "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。", + "agent_token_and_discovery_token_must_not_both_ca76bf69": "agent_token 和 discovery_token 不能同时为空", + "agent_token_is_empty_and_discovery_token_is_0349f2b6": "agent_token 为空且未配置 discovery_token", + "all_objects_have_been_migrated_93287840": "所有对象迁移完毕", + "already_running_the_latest_version_6e865701": "当前已是最新版本", + "an_asset_in_the_same_release_was_replaced_2878205a": "检测到同一 Release 的资源被替换,需要确认", + "an_asset_in_the_same_release_was_replaced_40e8d33a": "检测到同一 Release 的资源已被替换,请刷新并确认当前版本", + "an_update_is_already_in_progress_d5dae3bf": "已有升级任务正在执行", + "another_storage_migration_task_is_already_running_937fbb49": "另一个存储迁移任务正在运行中", + "api_base_url_optional_e966f73e": "API 基础地址 (可选)", + "api_reverse_proxy_backend_must_be_a_valid_4c68d5b8": "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL", + "api_reverse_proxy_match_path_must_start_with_662f1449": "API 反代匹配路径必须以 '/' 开头", + "archive_contains_an_invalid_path_s_0f89f375": "归档包含非法路径: %s", + "archive_path_escapes_the_destination_s_c6d68c24": "归档路径越界: %s", + "archives_8f10dc14": "压缩包", + "asynchronous_task_task_name_id_task_id_completed_128e1c5d": "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。", + "audio_296c632e": "音频", + "authentication_source_id_must_not_be_empty_87431a43": "认证源 ID 不能为空", + "authentication_source_is_disabled_7a948dfa": "认证源未启用", + "authentication_source_must_not_be_empty_8c3909a8": "认证源不能为空", + "authentication_source_name_may_only_contain_letters_digits_71129406": "认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头", + "authentication_source_name_must_not_be_empty_f85c91bb": "认证源名称不能为空", + "authentication_source_type_must_be_oidc_3d9726ab": "认证源类型仅支持 oidc", + "automatic_binary_replacement_is_not_supported_on_this_ef024e23": "当前平台暂不支持自动替换二进制", + "automatic_ip_group_configuration_must_be_a_json_535c4571": "自动 IP 组配置必须是 JSON 对象", + "automatic_notification_triggered_when_an_asynchronous_task_finishes_b0349623": "异步任务执行完毕触发的自动通知", + "automatic_rules_completed_matched_d_ips_currently_active_a2f333b5": "自动规则执行成功,共命中 %d 个 IP,当前生效 %d 个 IP", + "automatic_ssl_renewal_failed_v_b3366eb5": "SSL 自动续期失败: %v", + "automatic_ssl_renewal_task_completed_b95fcc39": "SSL 自动续期任务完成", + "automatic_updates_will_be_available_in_a_later_d19f8d56": "自动更新将在后续阶段开放,当前必须保持关闭", + "available_variables_title_description_content_url_to_example_5b046232": "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}", + "backend_url_is_required_when_the_api_reverse_8563c33e": "启用 API 反代时,后端服务地址不能为空", + "batch_completed_last_id_d_generated_d_hits_c7365ae6": "批次完成,末尾 ID: %d,生成: %d,命中: %d,失败: %d", + "block_response_body_for_node_s_must_not_8df3d52b": "节点 %s 的阻止响应体不能超过 %d 字节", + "block_status_code_for_node_s_must_be_3708ab21": "节点 %s 的阻止状态码必须在 400-599 之间", + "bot_access_token_issued_by_botfather_ef54847f": "通过 BotFather 申请到的机器人 Access Token", + "bot_token_7ed368ad": "机器人 Token (Bot Token)", + "both_latitude_and_longitude_are_required_451abfe8": "地图坐标必须同时填写纬度和经度", + "bucket_minutes_must_be_5_10_15_30_838c287d": "bucket_minutes 仅支持 5、10、15、30、60", + "bucket_started_at_must_be_an_rfc3339_timestamp_2d909e88": "bucket_started_at 必须为 RFC3339 时间", + "built_in_template_identifiers_cannot_be_changed_4e094f31": "系统预置模板不可修改标识符", + "built_in_templates_cannot_be_deleted_dd8af484": "系统预置模板不可删除", + "cannot_be_retried_93537915": "不支持重试", + "cannot_convert_configuration_s_value_s_to_a_30f99a83": "配置 %s 的值 '%s' 无法转换为布尔值: %w", + "cannot_convert_configuration_s_value_s_to_a_d601455b": "配置 %s 的值 '%s' 无法转换为decimal: %w", + "cannot_convert_configuration_s_value_s_to_an_bea99a42": "配置 %s 的值 '%s' 无法转换为整数: %w", + "captcha_verification_failed_missing_verification_credential_2b70bfd8": "验证码验证失败,缺少验证码凭证", + "captcha_verification_failed_or_expired_please_try_again_83606d54": "验证码校验失败或已过期,请重试", + "certificate_directory_must_not_be_empty_71b05ede": "cert dir 不能为空", + "certificate_file_path_must_not_be_empty_9fb64a51": "cert file path 不能为空", + "certificate_id_48409532": "证书 ID", + "certificate_id_must_be_nonzero_742f9d12": "证书 ID 不能为空或零", + "certificate_issuance_failed_v_c18caa92": "申请证书失败: %v", + "certificate_s_id_d_is_not_acme_managed_0703919b": "证书 %s (ID=%d) 不是 ACME 托管证书,无法自动续期 (Provider: %s)", + "certificate_s_is_not_acme_managed_and_cannot_e4d6469f": "证书 %s 不是 ACME 托管证书,无法自动续期", + "certificate_s_renewed_successfully_5ddb1448": "证书 %s 续签成功", + "channel_test_notification_2a7a7219": "通道测试通知", + "check_checking_github_release_repo_s_asset_s_9f927192": "[check] 正在检查 GitHub Release:repo=%s asset=%s", + "check_existing_files_89d94c02": "检查存量文件", + "check_github_provider_backoff_source_id_d_status_9b362b41": "[check] GitHub provider 退避:source_id=%d status=%d retry_at=%s", + "check_or_synchronize_a_pages_project_deployment_source_bdfc6949": "检查或同步 Pages 项目部署源", + "check_source_is_running_another_task_skipping_source_ca7ae2fd": "[check] 来源正在执行其它任务,跳过:source_id=%d", + "checking_cloudflare_group_primary_and_standby_node_status_e2df040e": "开始检查 Cloudflare 节点分组主备状态", + "cleaned_up_d_task_execution_logs_d_high_2352273f": "成功清理任务执行日志 %d 条(高频 %d 条,低频 %d 条)", + "cleaning_up_expired_logs_using_log_database_retention_c4163ab0": "开始清理过期日志(访问日志按当前日志库保留天数,性能指标按独立短留存)...", + "cleaning_up_notification_audit_history_retaining_the_last_6074bdfe": "开始清理历史推送审计日志,只保留最近7天数据...", + "cleaning_up_task_execution_logs_retaining_3_days_5f4a5eb2": "开始清理任务执行日志:高频任务保留最近3天,低频任务保留最近30天...", + "cleanup_compensating_unreferenced_deployment_archive_records_eb828b5c": "[cleanup] 正在补偿未引用的部署包记录", + "cleanup_completed_fce56fa2": "清理成功", + "cleanup_orphan_candidates_d_compensated_d_referenced_d_21a3822b": "[cleanup] orphan 候选=%d,已补偿=%d,仍被引用=%d,lease busy=%d,非法 marker=%d,跳过=%d,失败=%d", + "clickhouse_is_disabled_migration_to_clickhouse_is_unavailable_7bbf52f9": "ClickHouse 未启用,无法迁移到 ClickHouse", + "client_id_and_client_secret_are_required_before_b1904682": "启用认证源前必须配置 Client ID 和 Client Secret", + "cloudflare_api_request_failed_8fbea766": "cloudflare API 请求失败", + "cloudflare_api_request_failed_w_1f530c09": "cloudflare API 请求失败: %w", + "cloudflare_api_retry_limit_exceeded_7e3673cc": "cloudflare API 请求超过重试次数", + "cloudflare_api_token_is_inactive_4a0ca3bf": "cloudflare API Token 未激活", + "cloudflare_dns_synchronization_failed_08371454": "Cloudflare DNS 同步失败", + "cloudflare_domain_synchronization_8b6eca62": "Cloudflare 域名同步", + "cloudflare_domain_synchronization_succeeded_b3b55a0a": "Cloudflare 域名同步成功", + "cloudflare_domain_synchronization_succeeded_s_s_proxied_v_5b0eed54": "Cloudflare 域名同步成功: %s → %s (proxied=%v)", + "cloudflare_group_synchronization_d98bfd8f": "Cloudflare 分组同步", + "cloudflare_node_failover_check_57f894bc": "Cloudflare 节点故障回退检查", + "cloudflare_node_failover_check_completed_switched_d_groups_1dd7476d": "Cloudflare 节点故障回退检查完成,切换分组 %d 个", + "cloudflare_node_failover_check_failed_error_v_52840d43": "Cloudflare 节点故障回退检查失败: error=%v", + "cloudflare_node_synchronization_ed5311ec": "Cloudflare 节点同步", + "cloudflare_resource_was_not_found_8194db78": "Cloudflare 资源不存在", + "cloudflare_s_synchronization_completed_s_d_domains_3d341ca3": "Cloudflare %s同步完成: %s 共 %d 个域名", + "cloudflare_s_synchronization_completed_s_has_no_domain_302881ff": "Cloudflare %s同步完成: %s 无域名成员", + "cloudflare_s_synchronization_completed_s_s_d_domains_6aa7c0fd": "Cloudflare %s同步完成: %s → %s,共 %d 个域名", + "compensate_orphaned_archives_recover_expired_leases_and_check_dd5bf0ee": "补偿孤儿部署包、恢复过期执行权并串行检查到期的 GitHub latest 部署源", + "complete_https_endpoint_url_for_example_https_api_321a9514": "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook", + "complete_json_configuration_of_the_target_storage_backend_2a07e361": "待迁移到的目标存储引擎完整配置 JSON 字符串", + "config_must_not_be_empty_a3a68460": "config 不能为空", + "configuration_contains_an_additional_json_value_b7e289fa": "配置包含额外 JSON 值", + "configuration_key_already_exists_6d757a67": "配置键已存在", + "configuration_key_must_not_be_empty_e5cb19c0": "配置键不能为空", + "configuration_must_not_be_null_32b5f98f": "配置不能为 null", + "configuration_path_is_not_initialized_6d59fe3e": "config path 未初始化", + "configuration_value_must_not_be_empty_20e379d9": "配置值不能为空", + "configure_telegram_bot_notifications_9bffc349": "配置 Telegram 机器人推送消息。", + "configured_cloudflare_api_token_is_invalid_0453f265": "配置的 Cloudflare API Token 无效", + "connecting_to_smtp_server_s_d_username_s_0186b04e": "连接 SMTP 服务器: %s:%d, 用户名: %s", + "content_of_the_email_to_send_77c2373e": "发送邮件的内容主体", + "copied_d_access_logs_through_id_d_d6f86229": "已复制访问日志 %d 条(截至 id=%d)", + "copied_d_user_access_logs_through_id_d_f80f625f": "已复制用户访问日志 %d 条(截至 id=%d)", + "copied_s_d_records_through_id_d_ee8dd5f7": "已复制 %s %d 条(截至 id=%d)", + "copy_log_data_and_switch_the_log_database_0576d9c2": "复制迁移日志数据并在成功后切换日志主库(期间禁止日志写入)", + "copy_the_webhook_url_from_the_feishu_group_d0638f81": "从飞书群机器人设置中复制 of Webhook URL", + "current_migration_batch_completed_progress_d_d_42eb3c40": "当前批次迁移完成。迁移进度: %d/%d", + "current_password_is_incorrect_36d208b0": "原密码不正确", + "current_storage_has_no_objects_to_migrate_active_533b6b0a": "当前存储没有需要迁移的对象,活动存储已切换为 %s", + "custom_card_json_template_optional_be356c13": "自定义卡片 JSON 模版 (可选)", + "custom_notification_channel_f6ac176a": "自定义消息通道", + "custom_user_agent_pattern_in_node_s_must_1ae42a76": "节点 %s 的自定义 UA 正则不能为空", + "custom_user_agent_pattern_in_node_s_must_3372446d": "节点 %s 的自定义 UA 正则不能超过 %d 字节", + "d_d_domain_record_no_longer_exists_removing_397ee9a8": "[%d/%d] 域名记录已不存在,清理孤立成员: member_id=%d zone_domain_id=%d", + "d_d_failed_domain_s_member_id_d_6e126949": "[%d/%d] 失败: domain=%s member_id=%d error=%v", + "d_d_failed_to_remove_orphaned_member_member_7b0a42ab": "[%d/%d] 清理孤立成员失败: member_id=%d error=%v", + "d_d_succeeded_domain_s_2eb31693": "[%d/%d] 成功: domain=%s", + "d_d_synchronizing_domain_s_member_id_d_593b8a8a": "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)", + "d_days_4a67fe38": "%d天", + "d_hours_e7b10e79": "%d小时", + "d_minutes_2bc2266f": "%d分钟", + "d_seconds_26064316": "%d秒钟", + "database_is_not_initialized_0ab7ea2d": "数据库未初始化", + "database_update_updating_file_path_s_s_35e484ff": "[更新数据库] 正在更新文件路径: %s -> %s", + "days_5ab7e0f9": " 天 ", + "default_chat_id_optional_a982d25a": "默认会话 ID (Chat ID) (可选)", + "default_recipient_chat_id_used_when_the_notification_248a34c6": "默认的消息接收 Chat ID。如果通知事件中未配置 targets,将推送到此 ID", + "deleted_d_notification_history_records_cutoff_s_ca03970f": "成功删除 %d 条历史推送记录 (截止时间: %s)", + "deleted_successfully_5223f91b": "删除成功", + "deliver_notifications_through_a_feishu_group_bot_webhook_c851f7b2": "配置飞书群自定义机器人的 Webhook 接口投递。", + "deployment_archive_at_the_url_exceeds_the_size_51b0e2ec": "链接指向的部署包超过大小限制", + "deployment_source_saved_but_the_initial_check_could_a4d74ed9": "部署源已保存,但首次检查任务入队失败,请稍后手动检查", + "destination_email_address_bb129242": "接收邮件的目标邮箱地址", + "development_builds_do_not_support_automatic_updates_bb3a2946": "开发版本无法执行自动升级", + "discovery_registration_response_is_missing_node_id_or_4823814c": "discovery register response 缺少 node_id 或 agent_token", + "dispatch_automatic_update_dispatched_source_id_d_execution_72ffca53": "[dispatch] 已投递自动更新:source_id=%d execution_id=%s revision=%s", + "dispatch_system_notifications_asynchronously_across_multiple_channels_0c5c685b": "异步执行系统通知的多渠道派发与推送", + "dns_account_parameters_are_incomplete_fbcb0fad": "DNS 账号参数不完整", + "documents_2687ccdb": "文档", + "domain_already_exists_a59a13de": "域名已存在", + "domain_does_not_belong_to_this_zone_bb1bc93f": "域名不属于该 Zone", + "domain_is_bound_to_a_proxy_route_remove_ad0a62b7": "域名已绑定反代路由,请先解除绑定", + "domain_name_must_not_be_empty_60a8eab8": "域名不能为空", + "domain_synchronization_failed_domain_s_member_id_d_31ec4ac5": "域名同步失败: domain=%s member_id=%d error=%v", + "domain_synchronization_failed_member_id_d_error_v_36d3d285": "域名同步失败: member_id=%d error=%v", + "domain_synchronization_succeeded_domain_s_desired_target_s_18cedff2": "域名同步成功: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s", + "domain_synchronization_succeeded_member_id_d_2906410a": "域名同步成功: member_id=%d", + "domain_was_not_found_e3b18d21": "域名不存在", + "download_downloading_github_release_asset_repo_s_asset_f3760632": "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", + "download_fetching_remote_deployment_archive_9c58137d": "[download] 正在获取远程部署包", + "duplicate_edge_id_s_59427f0d": "边 ID %s 重复", + "duplicate_node_id_s_6f7764f9": "节点 ID %s 重复", + "edge_id_must_not_be_empty_049cfbf2": "边 ID 不能为空", + "edge_s_references_missing_source_node_s_b14a641b": "边 %s 的源节点 %s 不存在", + "edge_s_references_missing_target_node_s_f38f63a4": "边 %s 的目标节点 %s 不存在", + "edge_s_source_port_s_is_not_valid_445c7518": "边 %s 的源端口 %s 不适用于节点 %s", + "email_address_is_already_registered_ca59e824": "该邮箱已被注册", + "email_address_is_already_registered_f861225c": "邮箱已被注册", + "email_address_must_not_be_empty_28e55875": "邮箱不能为空", + "email_address_must_not_be_empty_2e477cb1": "邮箱地址不能为空", + "email_address_or_verification_code_is_missing_2b477d98": "邮箱或验证码未填写", + "email_body_db38bae9": "邮件内容 (Body)", + "email_notification_channel_70b4e93c": "邮件推送通道", + "email_notifications_use_the_global_smtp_settings_no_9b002fd1": "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。", + "email_sent_successfully_to_38e67ca0": "邮件成功发送至: ", + "email_subject_98a8f04e": "邮件主题 (Subject)", + "enter_a_cloudflare_api_token_00262e30": "请填写 Cloudflare API Token", + "enter_a_valid_json_request_body_template_variables_34eba3ef": "在此输入请求体,支持模板变量,必须为合法的 JSON 格式", + "enter_the_certificate_id_f0f6ff01": "请输入证书 ID", + "enter_the_cloudflare_target_group_id_830db5d3": "请输入 Cloudflare 指向分组 ID", + "enter_the_cloudflare_target_member_id_4a062db8": "请输入 Cloudflare 指向成员 ID", + "enter_the_complete_request_url_using_https_08ac14d8": "在此填写完整的请求地址,必须使用 HTTPS 协议", + "enter_the_deployment_archive_download_url_2fe91a17": "请填写部署包下载链接", + "enter_the_email_body_html_is_supported_ca9985b5": "请输入邮件内容(支持 HTML 格式)", + "enter_the_email_subject_852e1d02": "请输入邮件主题", + "enter_the_secret_if_signature_verification_is_enabled_fd5f494e": "可选,若机器人启用了安全设置中的签名校验,请在此输入", + "enter_the_telegram_bot_token_dcc672a5": "在此输入 Telegram 机器人的 Bot Token", + "enter_username_e4435775": "请输入用户名: ", + "error_user_s_was_not_found_81b9b991": "错误: 用户 '%s' 不存在\n", + "event_key_2f70299f": "事件标识", + "executor_is_not_configured_5d41479d": "executor 未配置", + "existing_files_were_found_use_the_storage_migration_e099a954": "存在存量文件,请通过存储迁移任务切换存储引擎", + "expression_in_automatic_rule_d_must_not_be_f696189a": "自动规则 %d 的 Expr 表达式不能为空", + "external_account_binding_information_is_incomplete_180d928a": "外部账号绑定信息不完整", + "extracted_executable_exceeds_the_size_limit_9461c1e6": "解压后的程序文件超过大小限制", + "extracted_pages_deployment_exceeds_the_size_limit_4cbef66f": "pages 部署包展开后体积超过限制", + "failed_to_back_up_current_executable_w_3addff1b": "备份当前程序失败: %w", + "failed_to_bind_parameters_5c410c6f": "参数绑定失败", + "failed_to_bind_parameters_provide_a_valid_array_b77c7672": "参数绑定失败,请传入有效的文件 ID 数组", + "failed_to_check_existing_files_w_a851b86d": "检查存量文件失败: %w", + "failed_to_clean_up_expired_logs_v_e8791024": "清理过期日志失败: %v", + "failed_to_clean_up_github_release_temporary_file_00bf359d": "GitHub Release 临时文件清理失败", + "failed_to_clean_up_remote_source_temporary_file_d721acd8": "清理远程来源临时文件失败", + "failed_to_clean_up_task_execution_logs_v_376be8e5": "清理任务执行日志失败: %v", + "failed_to_clean_up_upload_id_d_v_cf2710b1": "清理上传文件失败 [ID:%d]: %v", + "failed_to_clear_target_access_logs_w_9ff48e23": "清空目标访问日志失败: %w", + "failed_to_clear_target_user_access_logs_w_d7c2ad8c": "清空目标用户访问日志失败: %w", + "failed_to_close_update_archive_w_0a42c60e": "关闭升级归档失败: %w", + "failed_to_compile_waf_rule_s_w_4392b9dc": "WAF 规则 %s 编译失败: %w", + "failed_to_copy_s_w_b0890ed6": "复制 %s 失败: %w", + "failed_to_count_active_uploads_v_eccd0ce3": "统计活跃上传记录失败: %v", + "failed_to_count_notification_history_for_cleanup_v_87bcd35d": "统计待清理的历史推送记录失败: %v", + "failed_to_count_tokens_please_try_again_later_d6be07bb": "查询令牌数量失败,请稍后再试", + "failed_to_create_retry_execution_record_w_250258f4": "创建重试任务执行记录失败: %w", + "failed_to_create_task_execution_record_w_7bafae3b": "创建任务执行记录失败: %w", + "failed_to_create_token_please_try_again_later_268e7466": "创建令牌失败,请稍后再试", + "failed_to_create_update_archive_w_0e1ae531": "创建升级归档失败: %w", + "failed_to_create_update_directory_w_f3414e06": "创建升级目录失败: %w", + "failed_to_create_update_download_request_w_ca826654": "创建升级下载请求失败: %w", + "failed_to_create_update_request_92de6fa7": "创建更新请求失败", + "failed_to_create_user_fe2017cc": "创建用户失败", + "failed_to_delete_cloudflare_dns_record_905df10a": "删除 Cloudflare DNS 记录失败", + "failed_to_delete_file_485d2b67": "删除文件失败", + "failed_to_delete_notification_history_v_4308a791": "删除历史推送记录失败: %v", + "failed_to_delete_schedule_82d7ea83": "删除定时任务失败", + "failed_to_delete_token_please_try_again_later_8a65e358": "删除令牌失败,请稍后再试", + "failed_to_delete_user_7cf04bd0": "删除用户失败", + "failed_to_dispatch_task_40f01141": "任务下发失败", + "failed_to_download_deployment_archive_from_url_ece95602": "从链接下载部署包失败", + "failed_to_download_github_release_asset_445f42e6": "GitHub Release asset 下载失败", + "failed_to_download_subscription_w_73516643": "下载订阅失败: %w", + "failed_to_download_update_asset_http_d_8bfeec63": "下载升级资产失败: HTTP %d", + "failed_to_download_update_asset_w_15a3a529": "下载升级资产失败: %w", + "failed_to_drain_log_write_queue_w_a0166ebc": "排空日志写入队列失败: %w", + "failed_to_encrypt_password_v_92ab4495": "加密密码失败: %v\n", + "failed_to_enqueue_pages_deployment_source_task_821d3915": "pages 部署源任务入队失败", + "failed_to_enqueue_retry_task_w_2c39873e": "重试任务入队失败: %w", + "failed_to_enqueue_retry_v_5d869929": "重试入队失败: %v", + "failed_to_enqueue_task_v_a7c1aed2": "入队失败: %v", + "failed_to_enqueue_task_w_9e65a379": "任务入队失败: %w", + "failed_to_enqueue_verification_email_task_please_try_ac66b52f": "投递验证邮件发送任务失败,请重试", + "failed_to_execute_automatic_rule_w_2271cde6": "执行自动规则失败: %w", + "failed_to_extract_update_asset_w_692981b3": "解压升级资产失败: %w", + "failed_to_fetch_upstream_version_e19bb5de": "获取上游版本失败", + "failed_to_generate_random_password_v_f0fea5b7": "生成随机密码失败: %v\n", + "failed_to_generate_token_c78757e3": "生成令牌失败", + "failed_to_generate_verification_code_please_try_again_d83752a4": "生成验证码失败,请重试", + "failed_to_get_certificate_record_id_d_v_6d59f24e": "获取证书记录失败 ID=%d: %v", + "failed_to_get_certificate_record_w_214148cd": "获取证书记录失败: %w", + "failed_to_get_latest_version_w_fca8b7de": "获取最新版本失败: %w", + "failed_to_get_log_database_status_da58b757": "获取日志库状态失败", + "failed_to_get_preview_version_w_d61ed476": "获取 preview 版本失败: %w", + "failed_to_get_specified_version_w_63e83abe": "获取指定版本失败: %w", + "failed_to_get_token_list_please_try_again_96e34095": "获取令牌列表失败,请稍后再试", + "failed_to_get_user_list_27ad5285": "获取用户列表失败", + "failed_to_initialize_log_database_configuration_w_adfaefb2": "初始化日志主库配置失败: %w", + "failed_to_initialize_log_storage_20fdb546": "日志存储初始化失败", + "failed_to_initialize_system_options_783dd4ca": "系统选项初始化失败", + "failed_to_initialize_test_storage_instance_w_e45afe79": "初始化测试存储实例失败: %w", + "failed_to_list_s_members_name_s_id_fa26680a": "列出%s成员失败: name=%s id=%d error=%v", + "failed_to_load_active_node_group_s_active_4064ae7a": "加载生效节点失败: group=%s active_node_id=%d error=%v", + "failed_to_load_group_group_id_d_error_af85458e": "加载分组失败: group_id=%d error=%v", + "failed_to_load_member_context_member_id_d_5dba6ac0": "加载成员上下文失败: member_id=%d error=%v", + "failed_to_load_node_node_id_d_error_e404b970": "加载节点失败: node_id=%d error=%v", + "failed_to_locate_current_executable_w_8e100f0b": "定位当前程序失败: %w", + "failed_to_migrate_zone_d_conflicts_w_fd8934cb": "迁移 Zone 失败(%d 个冲突): %w", + "failed_to_open_file_756f4b8e": "打开文件失败", + "failed_to_parse_current_storage_configuration_w_6eb179f7": "解析当前存储配置失败: %w", + "failed_to_parse_directory_display_configuration_w_b45c310f": "解析目录显示配置失败: %w", + "failed_to_parse_email_parameters_v_2d5f065f": "解析邮件发送参数失败: %v", + "failed_to_parse_email_parameters_w_0af9b984": "解析邮件发送参数失败: %w", + "failed_to_parse_image_cache_warmup_parameters_w_80cc27e9": "解析图片缓存预热参数失败: %w", + "failed_to_parse_json_subscription_w_7171f213": "JSON 订阅解析失败: %w", + "failed_to_parse_notification_parameters_v_7c7c0d1a": "解析推送参数失败: %v", + "failed_to_parse_parameters_w_d098976c": "参数解析失败: %w", + "failed_to_parse_preview_version_information_8c63f444": "解析 preview 版本信息失败", + "failed_to_parse_target_storage_configuration_w_dedc816a": "解析目标存储配置失败: %w", + "failed_to_parse_version_information_9f1ec272": "解析版本信息失败", + "failed_to_precreate_target_postgresql_partitions_w_97bd4df9": "预建目标 PG 分区失败: %w", + "failed_to_process_file_9a1e91ff": "处理文件失败", + "failed_to_process_image_id_d_v_f89e5f9a": "图片处理失败 [ID:%d]: %v", + "failed_to_query_access_trends_740f8684": "查询访问趋势失败: ", + "failed_to_query_active_users_d78284d9": "查询活跃用户失败: ", + "failed_to_query_browser_distribution_483ef559": "查询浏览器分布失败: ", + "failed_to_query_file_count_b2b5322f": "查询文件数量失败", + "failed_to_query_file_list_a50606c2": "查询文件列表失败", + "failed_to_query_file_record_f2d055b2": "查询文件记录失败", + "failed_to_query_image_upload_records_v_72a8a849": "查询图片上传记录失败: %v", + "failed_to_query_images_for_cache_warmup_w_daee6ed8": "查询待预热图片失败: %w", + "failed_to_query_unused_uploads_v_4b22bf86": "查询未使用的上传文件失败: %v", + "failed_to_query_unused_uploads_w_915f2d8f": "查询未使用的上传文件失败: %w", + "failed_to_query_user_information_w_612e6300": "查询用户信息失败: %w", + "failed_to_query_user_v_31dc6f02": "查询用户失败: %v\n", + "failed_to_read_aggregate_statistics_v_1fafe12a": "读取总量统计失败: %v", + "failed_to_read_log_database_configuration_w_8d46eff5": "读取日志主库配置失败: %w", + "failed_to_read_log_database_v_f6136632": "读取日志主库失败: %v", + "failed_to_read_log_database_w_0502e3ef": "读取日志主库失败: %w", + "failed_to_read_source_access_log_time_range_d9667403": "读取源访问日志时间范围失败: %w", + "failed_to_read_source_user_access_log_time_691f3bb2": "读取源用户访问日志时间范围失败: %w", + "failed_to_read_subscription_content_w_e3665864": "读取订阅内容失败: %w", + "failed_to_read_username_v_f5ab6d40": "读取用户名失败: %v\n", + "failed_to_rebuild_file_storage_statistics_v_3a12ad55": "重算文件存储统计失败: %v", + "failed_to_remove_previous_backup_w_9f9ce6cb": "删除旧备份失败: %w", + "failed_to_render_verification_email_template_w_6191557f": "渲染验证邮件模板失败:%w", + "failed_to_replace_current_executable_w_a286fcde": "替换当前程序失败: %w", + "failed_to_reset_password_v_628117ce": "重置密码失败: %v\n", + "failed_to_resolve_current_executable_path_w_fd2316e8": "解析当前程序路径失败: %w", + "failed_to_resolve_github_release_request_target_4f4e6fdc": "GitHub Release 请求目标解析失败", + "failed_to_retrieve_file_records_fd17cc6c": "检索文件记录失败", + "failed_to_retry_task_147b8518": "任务重试失败", + "failed_to_revoke_access_token_please_try_again_d969614c": "吊销 Access Token 失败,请稍后再试", + "failed_to_rotate_token_please_try_again_later_59dc9b53": "轮换令牌失败,请稍后再试", + "failed_to_save_file_66b12cc8": "保存文件失败", + "failed_to_save_schedule_ceb33e03": "保存定时任务失败", + "failed_to_save_upload_record_e5b8160e": "保存上传记录失败", + "failed_to_send_email_v_36c06c07": "邮件发送失败: %v", + "failed_to_send_email_w_b705989e": "发送邮件失败: %w", + "failed_to_send_notification_title_s_v_360fc6f5": "消息推送失败 (标题: %s): %v", + "failed_to_serialize_storage_configuration_w_f9ccaed4": "序列化存储配置失败: %w", + "failed_to_set_executable_permissions_w_42a54b5d": "设置程序执行权限失败: %w", + "failed_to_update_file_record_dabd703b": "更新文件记录失败", + "failed_to_update_last_login_time_please_try_97d1603d": "更新登录时间失败,请稍后再试", + "failed_to_update_password_please_try_again_later_fb9d6cde": "更新密码失败,请稍后再试", + "failed_to_update_user_information_b39e7d62": "更新用户信息失败", + "failed_to_update_user_status_6fdd94ea": "更新用户状态失败", + "failed_to_validate_storage_parameters_w_4193c860": "验证存储配置参数失败: %w", + "failed_to_validate_target_storage_parameters_w_103ef6e7": "验证目标存储配置参数失败: %w", + "failed_to_write_notification_audit_history_v_b1891968": "写入推送历史审计记录失败: %v", + "failed_to_write_target_access_logs_batch_d_47059872": "写入目标访问日志失败(批 %d): %w", + "failed_to_write_target_user_access_logs_batch_9571e74b": "写入目标用户访问日志失败(批 %d): %w", + "failed_to_write_update_archive_w_c5f4d1ca": "写入升级归档失败: %w", + "feishu_group_bot_de2e961b": "飞书群机器人", + "fetched_migration_batch_requested_size_d_actual_objects_b53f700f": "获取当前批次迁移对象,批次大小: %d,实际获取对象数: %d", + "file_content_does_not_match_its_extension_and_7033268f": "文件内容与扩展名不匹配,可能包含安全风险", + "file_record_was_not_found_a4f4b1ab": "文件记录未找到", + "file_size_must_not_exceed_32_mb_9fef6c20": "文件大小不能超过 32MB", + "file_storage_backend_and_connection_configuration_json_2ee5f58c": "文件存储驱动及连接配置(JSON)", + "file_storage_statistics_rebuilt_d_active_records_d_4576eca3": "文件存储统计重算完成,活跃记录 %d 条,统计文件数 %d,总大小 %d 字节", + "file_validation_failed_076673fd": "文件校验失败", + "file_was_not_found_b5877462": "文件未找到", + "fold_minutes_must_be_3_or_5_740e118a": "fold_minutes 仅支持 3 或 5", + "for_example_100123456789_or_channel_name_8e18bf43": "例如 -100123456789 或 @channel_name", + "found_d_uploads_to_clean_up_in_this_a86fa63c": "本批次找到 %d 个需要清理的上传文件", + "geoip_lookup_is_disabled_ed78d892": "GeoIP 查询已禁用", + "geolocation_provider_must_be_disabled_mmdb_ip_api_08bc0f62": "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo", + "github_latest_check_interval_must_be_between_5_2470787a": "GitHub latest 检查间隔必须在 5 到 1440 分钟之间", + "github_release_asset_digest_is_invalid_or_verification_7baba33c": "GitHub Release asset digest 无效或校验失败", + "github_release_asset_digest_verification_failed_98f540bd": "GitHub Release 资源摘要校验失败", + "github_release_asset_exceeds_the_size_limit_33eb67f0": "GitHub Release asset 超过大小限制", + "github_release_asset_is_empty_5a6a9b0a": "GitHub Release asset 内容为空", + "github_release_asset_name_must_be_a_safe_ee1a4e4e": "GitHub Release 资源名称必须是安全的文件名", + "github_release_asset_redirect_limit_exceeded_da3dd30b": "GitHub Release asset 重定向次数过多", + "github_release_check_completed_947726e7": "GitHub Release 检查完成", + "github_release_check_completed_content_is_unchanged_dbcf8dc0": "GitHub Release 检查完成,内容未变化", + "github_release_request_failed_e37d72ea": "GitHub Release 请求失败", + "github_release_request_target_is_not_a_public_80de5fc7": "GitHub Release 请求目标不是公网地址", + "github_release_sources_must_not_include_remote_url_84826183": "GitHub Release 来源不能包含远程地址配置", + "github_returned_an_unexpected_status_s_30454efa": "GitHub 返回异常状态: %s", + "global_rule_group_03239b06": "全局规则组", + "global_waf_rule_cannot_be_deleted_031e6c8d": "全局 WAF 规则不能删除", + "global_waf_rule_groups_do_not_require_manual_5cc46cb1": "全局 WAF 规则组不需要手动绑定", + "group_399ee693": "分组", + "group_id_4acad7d2": "分组 ID", + "group_id_must_be_nonzero_caa5a675": "分组 ID 不能为空或零", + "group_name_must_not_be_empty_7b33c238": "分组名称不能为空", + "group_primary_key_in_the_of_cf_pointing_34d94c54": "of_cf_pointing_groups 表中的分组主键 ID", + "historical_authentication_source_299cdda4": "历史认证源", + "hours_f8af2637": " 小时 ", + "https_api_base_url_defaults_to_https_api_84cf7d41": "接口请求的 HTTPS 基础地址,留空默认为 https://api.telegram.org", + "id_of_the_tls_certificate_to_renew_2e6077c7": "待续期的 TLS 证书 ID", + "id_token_verification_failed_21582415": "ID Token 验证失败", + "if_specified_this_must_be_valid_feishu_card_1541edd3": "若填写,必须是合法的飞书卡片 JSON 格式", + "image_cache_warmup_completed_processed_d_generated_d_9553c957": "图片缓存预热完成,共处理 %d 张,生成 %d 张,命中 %d 张,失败 %d 张", + "image_cache_warmup_parameters_must_not_be_empty_59ff5906": "图片缓存预热参数不能为空", + "image_quality_0ab5629b": "图片质量", + "image_quality_must_be_low_medium_or_high_3d699272": "图片质量仅支持 low、medium、high", + "images_d24c10d3": "图片", + "incorrect_username_or_password_a9a6a83a": "用户名或密码错误", + "ingest_saving_github_release_deployment_archive_04bc7ee9": "[ingest] 正在保存 GitHub Release 部署包", + "ingest_saving_managed_deployment_archive_ab0eb22f": "[ingest] 正在保存受管部署包", + "initialize_test_e2279a66": "初始化测试", + "internal_server_error_03654907": "服务器内部错误", + "internal_server_error_cc540d22": "内部服务器错误", + "internal_system_error_2216034c": "内部系统错误", + "invalid_access_mode_parameter_f4f87024": "无效的 access_mode 参数", + "invalid_account_binding_id_47be930f": "绑定记录 ID 无效", + "invalid_authentication_source_id_8d95b39c": "认证源 ID 无效", + "invalid_cache_rules_for_route_s_0b419f3f": "路由 %s 缓存规则无效", + "invalid_certificate_pem_content_82644642": "证书 PEM 内容不合法", + "invalid_cloudflare_connection_source_0f6d01bb": "无效的 Cloudflare 连接来源", + "invalid_cloudflare_failover_check_parameters_w_ef353c9b": "无效的 Cloudflare 故障回退检查参数: %w", + "invalid_cloudflare_group_synchronization_parameters_w_545b7a80": "无效的 Cloudflare 分组同步参数: %w", + "invalid_cloudflare_member_synchronization_parameters_w_43aa2390": "无效的 Cloudflare 成员同步参数: %w", + "invalid_cloudflare_node_synchronization_parameters_3f4794f4": "无效的 Cloudflare 节点同步参数", + "invalid_configuration_for_node_s_w_86c7c706": "节点 %s 的配置无效: %w", + "invalid_configuration_snapshot_format_6ecb7b3c": "配置快照格式无效", + "invalid_cron_expression_df504099": "无效的 Cron 表达式", + "invalid_cursor_parameter_315ceadf": "无效的 cursor 参数", + "invalid_custom_request_headers_for_route_s_705aeb8a": "路由 %s 自定义请求头无效", + "invalid_custom_user_agent_pattern_in_node_s_6a73d813": "节点 %s 的自定义 UA 正则无效: %s", + "invalid_deployment_archive_url_only_http_and_https_23d257c7": "部署包下载链接无效,仅支持 http/https", + "invalid_discovery_token_980df46c": "discovery Token 无效", + "invalid_dns_record_value_check_the_domain_name_eb1b034a": "DNS 记录值无效,请检查域名或 IP 地址", + "invalid_domain_name_format_614e6afb": "域名格式不合法", + "invalid_email_address_format_4e992d96": "邮箱格式不正确", + "invalid_expression_in_automatic_rule_s_w_a9ea3421": "自动规则 %s Expr 无效: %w", + "invalid_file_id_619affa0": "无效的文件 ID", + "invalid_github_release_asset_digest_format_3e38c702": "GitHub Release 资源摘要格式无效", + "invalid_github_release_metadata_response_190ecf90": "GitHub Release 元数据响应无效", + "invalid_github_release_request_parameters_4ceabd70": "GitHub Release 请求参数无效", + "invalid_github_release_selection_mode_d644ae98": "GitHub Release 选择方式无效", + "invalid_github_repository_url_use_https_github_com_4a255a79": "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}", + "invalid_graph_data_for_waf_rule_s_w_529ff00b": "WAF 规则 %s 的图数据无效: %w", + "invalid_graph_for_waf_rule_s_w_8a87d07b": "WAF 规则 %s 的图无效: %w", + "invalid_historical_version_snapshot_format_0424b7b0": "历史版本快照格式不合法", + "invalid_id_c0d026c8": "无效的 ID", + "invalid_id_value_s_2be5b057": "无效的 ID 值: %s", + "invalid_image_cache_warmup_parameters_v_94273376": "图片缓存预热参数无效: %v", + "invalid_image_cache_warmup_parameters_w_285d734c": "图片缓存预热参数格式无效: %w", + "invalid_ip_address_format_c6084aa5": "ip 格式无效", + "invalid_ip_address_format_f988e58e": "IP 格式无效", + "invalid_ip_group_type_2c9b1982": "IP 组类型无效", + "invalid_ip_match_configuration_in_active_waf_rule_9acb007b": "活动配置 WAF 规则 %d 节点 %s 的 IP 匹配配置无效: %w", + "invalid_json_format_w_be1f4f8e": "无效的 JSON 格式: %w", + "invalid_login_request_f39483be": "非法登录请求", + "invalid_lookback_format_use_a_duration_such_as_63f37246": "lookback 格式不合法,请使用 60m、1h 等时长", + "invalid_lookback_minutes_w_d9589ae4": "lookback_minutes 无效: %w", + "invalid_metadata_json_format_59d64290": "元数据 JSON 格式不合法", + "invalid_node_ip_address_format_6101324f": "节点 IP 格式无效", + "invalid_origin_address_format_0f1c1a36": "源站地址格式不合法", + "invalid_pages_configuration_for_route_s_active_pages_8a53c746": "路由 %s Pages 配置无效: pages 激活部署不匹配", + "invalid_pages_configuration_for_route_s_active_pages_bc33eb91": "路由 %s Pages 配置无效: pages 激活部署不存在", + "invalid_pages_configuration_for_route_s_no_pages_f8128192": "路由 %s Pages 配置无效: 未绑定 Pages 项目", + "invalid_pages_configuration_for_route_s_pages_deployment_296fe10f": "路由 %s Pages 配置无效: pages 部署校验和缺失", + "invalid_pages_configuration_for_route_s_pages_module_52fe9ed1": "路由 %s Pages 配置无效: pages 模块不可用", + "invalid_pages_configuration_for_route_s_pages_project_10fdf9a7": "路由 %s Pages 配置无效: pages 项目未启用", + "invalid_pages_configuration_for_route_s_pages_project_146a248a": "路由 %s Pages 配置无效: pages 项目没有激活部署", + "invalid_pages_configuration_for_route_s_pages_project_b6dacd48": "路由 %s Pages 配置无效: pages 项目不存在", + "invalid_pages_configuration_for_route_s_w_e055a8aa": "路由 %s Pages 配置无效: %w", + "invalid_pages_configuration_missing_pages_project_id_644ff7ea": "pages 配置无效: 缺少 pages_project_id", + "invalid_pages_deployment_source_task_parameters_329e38dc": "pages 部署源任务参数无效", + "invalid_pages_entry_file_w_dd997325": "pages 入口文件不合法: %w", + "invalid_pages_root_directory_w_288d75ab": "pages 根目录不合法: %w", + "invalid_pages_route_configuration_6e3033f8": "pages 路由配置无效", + "invalid_parameters_68abb8d5": "无效的参数", + "invalid_parameters_889c2534": "参数错误", + "invalid_port_format_301a25e5": "端口格式不合法", + "invalid_release_asset_size_d_71275aec": "release 资产大小无效: %d", + "invalid_remote_archive_url_use_http_https_without_cec74305": "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址", + "invalid_remote_source_archive_size_limit_ed9a749a": "远程来源部署包大小限制无效", + "invalid_request_rate_use_a_rate_such_as_7d899f0e": "请求频率格式不合法,请使用类似 10r/s、100r/m,或 -1 禁用", + "invalid_rule_graph_w_51b132a5": "规则图无效: %w", + "invalid_s_format_use_512k_1m_or_a_7ae562cc": "%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", + "invalid_s_format_use_a_rate_such_as_285ef3fc": "%s 格式不合法,请输入类似 10r/s、100r/m,或留空关闭", + "invalid_schedule_id_5439796e": "无效的定时任务ID", + "invalid_since_timestamp_rfc3339_is_required_e4d815e5": "since 时间格式无效,需为 RFC3339", + "invalid_smtp_configuration_use_a_temporary_code_to_707c19c2": "smtp 配置无效,使用临时码登录", + "invalid_spa_fallback_path_65e9fab1": "spa fallback 回退路径不合法", + "invalid_status_code_range_s_ea6a6bba": "无效状态码区间: %s", + "invalid_status_code_s_5fcf3c49": "无效状态码: %s", + "invalid_subscription_url_5e4fd4ef": "订阅 URL 无效", + "invalid_target_log_database_s_f49a2783": "目标日志库不合法: %s", + "invalid_task_execution_id_6a0914c6": "无效的任务执行记录 ID", + "invalid_task_type_42c573e7": "无效的任务类型", + "invalid_time_range_205ddcad": "无效的时间范围", + "invalid_time_range_select_24h_7d_or_30d_7315b848": "时间范围无效,请选择 24h、7d 或 30d", + "invalid_token_id_6493bfb3": "无效的令牌ID", + "invalid_until_timestamp_rfc3339_is_required_ff8c418e": "until 时间格式无效,需为 RFC3339", + "invalid_upload_id_5af291c3": "无效的上传ID", + "invalid_upstream_configuration_for_route_s_868edc8c": "路由 %s 上游配置无效", + "invalid_upstream_repository_url_1865af39": "上游仓库地址无效", + "invalid_upstream_version_response_fa50f23a": "上游版本响应无效", + "invalid_version_identifier_31e5ed95": "无效的版本号", + "ip_address_must_not_be_empty_4272c9f3": "IP 不能为空", + "ip_group_d_was_not_found_7b101cd1": "IP 组 %d 不存在", + "ip_group_is_referenced_by_waf_rules_remove_bd6b2cb2": "IP 组已被 WAF 规则引用,请先移除引用", + "ip_group_name_must_not_be_empty_56680071": "IP 组名称不能为空", + "ip_group_was_not_found_21fa23de": "IP 组不存在", + "ip_must_not_be_empty_fe4591d4": "ip 不能为空", + "json_mapping_rule_q_did_not_match_any_feb34054": "JSON 映射规则 %q 未匹配到内容", + "latitude_must_be_between_90_and_90_0b929ed3": "纬度必须在 -90 到 90 之间", + "leave_empty_to_use_the_built_in_interactive_1817bda4": "可选,留空则默认使用系统内置的精美互动卡片", + "less_than_d_minutes_since_the_last_synchronization_7e3c8aac": "距上次同步不足 %d 分钟,跳过执行", + "log_cleanup_completed_access_log_retention_d_days_c0f19eea": "日志清理完成:访问日志保留 %d 天,性能指标保留 %d 天,删除 %d 条", + "log_database_configuration_is_empty_2cf975c3": "日志主库配置为空", + "log_database_switched_from_s_to_s_32746f98": "日志数据库已从 %s 切换为 %s", + "log_database_switched_to_s_writes_resumed_66f1d3fc": "日志数据库已切换为 %s,写入恢复", + "log_storage_is_disabled_access_logs_cannot_be_4f3d313d": "日志存储未启用,无法检索访问日志", + "log_storage_is_disabled_analytics_are_unavailable_2633ef1d": "日志存储未启用,无法获取分析数据", + "longitude_must_be_between_180_and_180_3b3f5968": "经度必须在 -180 到 180 之间", + "lookback_must_be_a_whole_number_of_minutes_bd7a069b": "lookback 数值必须为整数分钟", + "lookback_window_must_not_exceed_s_ec8227f8": "回看窗口不能超过 %s", + "lua_directory_must_not_be_empty_73c45ce8": "lua dir 不能为空", + "lua_file_path_must_not_be_empty_ad5ec475": "lua file path 不能为空", + "main_configuration_path_must_not_be_empty_4257a895": "main config path 不能为空", + "match_mode_for_node_s_must_be_and_65944b17": "节点 %s 的匹配模式必须为 and 或 or", + "match_path_is_required_when_the_api_reverse_8b41181e": "启用 API 反代时,匹配路径不能为空", + "maximum_number_of_access_tokens_reached_3c769ae3": "已达到访问令牌最大创建数量限制", + "maximum_retry_8bf02f4c": "已达到最大重试", + "maximum_retry_count_d_reached_d961828b": "已达到最大重试次数 %d", + "maximum_retry_count_reached_122b9d92": "已达到最大重试次数", + "member_id_a4c4a726": "成员 ID", + "member_id_must_be_nonzero_dbec6bb1": "成员 ID 不能为空或零", + "member_primary_key_in_the_of_cf_pointing_95a864b8": "of_cf_pointing_members 表中的成员主键 ID", + "migrate_file_storage_f03b672a": "迁移文件存储", + "migrate_files_from_active_storage_to_the_target_89752f95": "将活动存储中的文件迁移到待切换的目标存储,迁移期间文件系统保持只读", + "migration_skipped_identical_file_already_exists_in_target_729b4c99": "[跳过迁移] 目标存储已存在相同文件: %s", + "migration_started_reading_file_from_source_storage_s_001c53d6": "[迁移开始] 正在从源存储读取文件: %s", + "migration_succeeded_file_migration_completed_s_9c0b6a24": "[迁移成功] 文件已完成迁移: %s", + "migration_target_postgres_for_a_postgresql_primary_sqlite_a542bb6f": "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse", + "minutes_c5e1cb2c": " 分钟 ", + "missing_agent_token_ad736eb5": "缺少 Agent Token", + "missing_certificate_file_eb3adf78": "缺少证书文件", + "missing_discovery_token_038af7e9": "缺少 Discovery Token", + "missing_pages_deployment_archive_0e2feedd": "缺少 Pages 部署包", + "missing_private_key_file_5b2ce6fe": "缺少私钥文件", + "missing_release_version_identifier_179171c9": "缺少发布版本号", + "mmdb_update_interval_must_be_greater_than_0_7b371ae5": "mmdb_update_interval 必须大于 0", + "monitor_scope_must_be_all_or_selected_sites_956be94b": "监控范围必须为全部站点 (all) 或选择站点 (selected)", + "months_15e40097": " 个月 ", + "multiple_cloudflare_dns_records_have_the_same_name_0487505e": "检测到 Cloudflare 中存在多条同名 DNS 记录,请先手动清理", + "must_be_an_integer_dfd6c0ce": "必须为整数", + "new_password_generated_randomly_if_omitted_acbf0a56": "新密码(若不指定,则自动生成随机密码)", + "new_password_must_contain_at_least_8_characters_16160d33": "新密码长度不能少于 8 位", + "new_password_s_c7c43a90": "新密码: %s\n", + "no_active_configuration_version_60f8a99b": "当前没有激活版本", + "no_available_authentication_source_is_configured_326f6438": "未配置可用认证源", + "no_certificate_is_configured_for_route_s_83ba5214": "路由 %s 未配置证书", + "no_cloudflare_connection_is_configured_6ff882c7": "尚未配置 Cloudflare 连接", + "no_compatible_release_was_found_4d9a5ae5": "未找到兼容的 Release", + "no_enabled_rules_are_available_to_publish_8dc1e2c9": "没有可发布的启用规则", + "no_expired_logs_require_cleanup_access_log_retention_c72c5317": "没有需要清理的过期日志 (访问日志保留 %d 天,性能指标保留 %d 天)", + "no_github_release_asset_matches_the_configuration_8c8ac16a": "未找到符合配置的 GitHub Release 资源", + "no_ip_addresses_or_ranges_were_parsed_from_1fcb1209": "JSON 订阅没有解析到 IP/IP 段", + "no_ip_geolocation_result_was_returned_6cb0600f": "未获取到 IP 归属结果", + "no_members_selected_dd913716": "未选择任何成员", + "no_notification_history_requires_cleanup_cutoff_s_a2893d9b": "没有需要清理的历史推送记录 (截止时间: %s)", + "no_preview_release_is_currently_available_558f10ae": "当前没有可用的 preview 发布", + "no_release_asset_was_found_for_the_current_a2eb352a": "未找到当前系统对应的 Release 资产", + "no_unique_cloudflare_zone_found_for_s_c20b7e4b": "cloudflare 中未找到唯一 Zone %s", + "no_valid_file_records_were_found_to_archive_89022def": "没有找到任何有效的文件记录进行打包", + "node_2410d860": "节点", + "node_id_must_not_be_empty_51a5de58": "节点 ID 不能为空", + "node_id_must_not_be_empty_5aa87f2e": "node_id 不能为空", + "node_identifier_conflict_please_try_again_8e2acef7": "节点标识生成冲突,请重试", + "node_ip_must_not_be_empty_f589f59a": "node_ip 不能为空", + "node_ip_must_not_exceed_64_characters_86cbe000": "节点 IP 不能超过 64 个字符", + "node_is_offline_or_the_synchronization_command_could_c560bf64": "节点不在线或通过 WebSocket 发送同步指令失败", + "node_location_name_must_not_exceed_128_characters_87961927": "节点位置名不能超过 128 个字符", + "node_name_must_not_be_empty_03dc8468": "节点名不能为空", + "node_name_must_not_be_empty_c7c245c4": "node_name 不能为空", + "node_s_cannot_validate_ip_group_d_37bca244": "节点 %s 无法校验 IP 组 %d", + "node_s_contains_invalid_browser_tag_s_66a80660": "节点 %s 的浏览器标签 %s 无效", + "node_s_contains_invalid_cidr_s_3cf57f3e": "节点 %s 的 CIDR %s 无效", + "node_s_contains_invalid_country_code_s_c3a43e69": "节点 %s 的国家代码 %s 无效", + "node_s_contains_invalid_ip_s_0c3bdf3f": "节点 %s 的 IP %s 无效", + "node_s_contains_invalid_operating_system_tag_s_5f6a6ddd": "节点 %s 的操作系统标签 %s 无效", + "node_s_contains_invalid_region_code_s_736f6724": "节点 %s 的地区代码 %s 无效", + "node_s_failed_to_validate_ip_group_d_ce8c1581": "节点 %s 校验 IP 组 %d 失败: %w", + "node_s_has_no_path_to_a_terminal_8286bb22": "节点 %s 不存在通往终止节点的路径", + "node_s_has_unknown_type_s_f090b1d7": "节点 %s 的类型 %s 未知", + "node_s_is_not_reachable_from_the_start_5eacc4af": "节点 %s 无法从开始节点到达", + "node_s_must_have_at_least_one_incoming_7c8aac64": "节点 %s 必须至少有一条入边", + "node_s_must_not_contain_more_than_d_404db8ac": "节点 %s 的自定义 UA 正则不能超过 %d 条", + "node_s_output_s_is_connected_to_multiple_05e9cddf": "节点 %s 的 %s 出口连接了多个目标", + "node_s_output_s_is_not_connected_6de47feb": "节点 %s 的 %s 出口未连接", + "node_s_references_an_invalid_ip_group_id_27bdf145": "节点 %s 引用的 IP 组 ID 无效", + "node_s_references_missing_ip_group_d_40d3c647": "节点 %s 引用的 IP 组 %d 不存在", + "node_s_requires_at_least_one_pattern_when_480bade2": "节点 %s 开启屏蔽自定义 UA 时至少需要一条正则", + "node_was_not_found_7b451929": "节点不存在", + "nonce_mismatch_possible_replay_attack_8b3e6356": "nonce 不匹配,可能存在重放攻击", + "none_484d5561": "无", + "not_found_4e51b67d": "不存在", + "not_signed_in_c1f65837": "未登录", + "notification_channel_test_245d0001": "测试通道推送", + "notification_failed_v_263fc07a": "推送失败: %v", + "notification_sent_s_s_e52650c7": "推送成功: [%s] -> %s", + "notification_sent_title_s_content_summary_s_097becda": "消息推送成功 (标题: %s, 内容摘要: %s)", + "observability_replay_minutes_must_be_greater_than_0_fc964575": "observability_replay_minutes 必须大于 0", + "oidc_authentication_sources_require_a_discovery_url_566d40ba": "OIDC 认证源必须配置 Discovery URL", + "only_administrators_can_create_tokens_with_administrator_privileges_b2e4ad01": "只有管理员才能创建具有管理员权限的令牌", + "only_automatic_and_subscription_ip_groups_support_synchronization_b7d7995c": "只有自动和订阅类型 IP 组支持同步", + "only_failed_tasks_464688af": "只有失败的任务", + "only_failed_tasks_can_be_retried_279c69a0": "只有失败的任务才能重试", + "only_failed_tasks_can_be_retried_current_status_9346f42a": "只有失败的任务才能重试,当前状态: %s", + "only_jpg_png_and_webp_images_are_supported_c7c3c323": "只支持 JPG、PNG、WEBP 格式的图片", + "openflare_automatic_ssl_renewal_cf123a50": "OpenFlare SSL 自动续期", + "openflare_pages_deployment_source_operation_866f0285": "OpenFlare Pages 部署源操作", + "openflare_pages_deployment_source_scan_6932f24c": "OpenFlare Pages 部署源扫描", + "openflare_single_certificate_ssl_renewal_a531316e": "OpenFlare 单证书 SSL 续期", + "openflare_uptime_kuma_synchronization_b2168d61": "OpenFlare Uptime Kuma 同步", + "openflare_waf_ip_group_synchronization_f7213a53": "OpenFlare WAF IP 组同步", + "openresty_observability_port_must_be_between_1_and_69074f9c": "openresty_observability_port 必须在 1-65535 之间", + "openrestymainconfigtemplate_must_not_be_empty_3ac2d43c": "OpenRestyMainConfigTemplate 不能为空", + "openrestymainconfigtemplate_must_retain_placeholder_s_cfc00d9c": "OpenRestyMainConfigTemplate 必须保留占位符 %s", + "operation_not_authorized_invalid_agent_token_307a5b9a": "无权进行此操作,Agent Token 无效", + "operation_not_authorized_invalid_registration_token_a1264630": "无权进行此操作,注册 Token 无效", + "operation_not_authorized_invalid_tunnel_token_641a1499": "无权进行此操作,Tunnel Token 无效", + "operation_not_permitted_286a8d3f": "无权操作", + "origin_address_already_exists_0013db55": "源站地址已存在", + "origin_address_is_missing_a_port_7b7c2ebf": "源站地址缺少端口", + "origin_address_must_not_be_empty_f49f8b30": "源站地址不能为空", + "origin_path_must_not_contain_a_protocol_8e0af122": "源站路径不能包含协议", + "origin_path_must_start_with_or_4bf64d2c": "源站路径需以 / 或 ? 开头", + "origin_protocol_must_be_http_or_https_ab499f2a": "源站协议仅支持 http 或 https", + "origin_was_not_found_0752eb2d": "源站不存在", + "other_d2909f16": "其他", + "pages_archives_support_zip_tar_gz_tar_xz_83bca097": "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式", + "pages_deployment_archive_hash_is_missing_4d740a0d": "pages 部署包哈希缺失", + "pages_deployment_archive_is_not_a_valid_compressed_9b2cefd1": "pages 部署包不是有效的压缩文件", + "pages_deployment_archive_is_not_a_valid_zip_0dd602e6": "pages 部署包不是有效 zip 文件", + "pages_deployment_archive_must_be_a_zip_file_ab5b4552": "pages 部署包必须是 .zip 文件", + "pages_deployment_archive_must_not_be_empty_ff4d94e4": "pages 部署包不能为空", + "pages_deployment_archive_must_not_exceed_d_mib_3c1a0b6a": "pages 部署包不能超过 %d MiB", + "pages_deployment_archive_path_is_empty_87435085": "pages 部署包路径为空", + "pages_deployment_archive_upload_record_was_not_found_9990e33d": "pages 部署包上传记录不存在", + "pages_deployment_archive_upload_type_mismatch_s_cd5886f7": "pages 部署包上传类型不匹配: %s", + "pages_deployment_archive_was_not_found_w_39714023": "pages 部署包不存在: %w", + "pages_deployment_does_not_belong_to_this_project_374852db": "pages 部署不属于该项目", + "pages_deployment_is_not_included_in_the_active_26b4dcae": "pages 部署尚未进入激活配置", + "pages_deployment_must_not_contain_more_than_d_80d2f448": "pages 部署文件数不能超过 %d", + "pages_deployment_source_configuration_changed_task_skipped_ed018def": "pages 部署源配置已变化,本次任务已跳过", + "pages_deployment_source_operation_failed_please_try_again_19363b45": "pages 部署源操作失败,请稍后重试", + "pages_deployment_source_synchronization_failed_86c51b71": "pages 部署源同步失败", + "pages_deployment_source_synchronized_and_published_successfully_0bf1e5dd": "Pages 部署源同步并发布成功", + "pages_deployment_source_task_ownership_is_no_longer_35aa212a": "pages 部署源任务执行权已失效", + "pages_deployment_source_was_not_found_c9af468e": "pages 部署源不存在", + "pages_deployment_was_not_found_e90bdf07": "pages 部署不存在", + "pages_entry_file_must_not_exceed_512_characters_3e7fb206": "pages 入口文件长度不能超过 512", + "pages_project_has_no_active_deployment_6d7971f7": "pages 项目没有激活部署", + "pages_project_is_disabled_98847c64": "pages 项目未启用", + "pages_project_is_referenced_by_rules_and_cannot_416af718": "pages 项目已被规则引用,不能删除", + "pages_project_name_must_not_be_empty_717063ef": "pages 项目名称不能为空", + "pages_project_or_deployment_is_empty_ddcf5875": "pages 项目或部署为空", + "pages_project_slug_already_exists_b7c6e058": "pages 项目标识已存在", + "pages_project_slug_may_only_contain_lowercase_letters_56675232": "pages 项目标识只能包含小写字母、数字和连字符", + "pages_project_was_not_found_b6f46c90": "pages 项目不存在", + "pages_root_directory_must_not_exceed_512_characters_29d92328": "pages 根目录长度不能超过 512", + "pages_source_content_is_unchanged_existing_deployment_reactivated_1441d50c": "Pages 部署源内容未变化,已重新激活现有部署", + "pages_source_scan_completed_recovered_d_leases_compensated_de889c15": "Pages 部署源扫描完成:恢复 %d 个租约,补偿 %d 个孤儿记录,检查 %d 个来源,投递 %d 个自动更新,积压 %d 个", + "parse_fbff7b50": "解析", + "parse_task_parameters_w_21ea4f33": "解析任务参数: %w", + "password_changed_successfully_c5299b82": "密码修改成功", + "password_encryption_failed_please_try_again_ef84e8d4": "密码加密失败,请重试", + "password_login_is_disabled_by_the_administrator_54c5d88a": "管理员关闭了密码登录", + "password_must_contain_at_least_8_characters_ab81f8d4": "密码长度不能少于 8 位", + "password_reset_successfully_bf63ac4e": "成功重置密码!", + "periodically_remove_unused_uploads_historical_notifications_and_expired_68ae5d13": "定期清理未使用上传文件、历史推送记录和过期任务执行日志", + "pg_dump_is_unavailable_install_the_postgresql_client_759ad5ae": "pg_dump 不可用,请确保服务器已安装 PostgreSQL 客户端工具", + "please_sign_in_first_48144f96": "请先登录", + "port_must_not_be_empty_e53d74a2": "端口不能为空", + "postgresql_is_disabled_and_sqlite_is_the_primary_3234d0c2": "PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL", + "postgresql_is_the_primary_database_sqlite_cannot_be_4d638671": "当前主库为 PostgreSQL,日志库不能设置为 SQLite", + "pow_algorithm_for_node_s_must_be_fast_97989167": "节点 %s 的 PoW 算法必须为 fast 或 slow", + "pow_challenge_ttl_for_node_s_must_be_87482278": "节点 %s 的 PoW 挑战 TTL 不能小于 30 秒", + "pow_difficulty_for_node_s_must_be_between_4eaec0b8": "节点 %s 的 PoW 难度必须在 1-16 之间", + "pow_session_ttl_for_node_s_must_be_98ed3573": "节点 %s 的 PoW 会话 TTL 不能小于 60 秒", + "precreated_target_postgresql_partitions_from_s_to_s_0bc5358f": "已为目标 PG 预建分区 %s ~ %s", + "preparing_group_synchronization_group_s_id_d_enabled_9d8361f3": "准备分组同步: group=%s id=%d enabled=%v target=%s default_proxied=%v", + "preparing_node_synchronization_node_s_id_d_ip_61c8f04a": "准备节点同步: node=%s id=%d ip=%s", + "preparing_to_issue_or_renew_a_certificate_for_18326882": "准备为域名 [%s] 申请/续期证书", + "preparing_to_send_email_to_s_subject_s_1f6fad47": "开始准备发送邮件到: %s, 主题: %s", + "previous_pages_deployment_source_task_lease_expired_4de3ac84": "上次 pages 部署源任务租约已过期", + "primary_and_standby_nodes_must_be_different_7f96a264": "主节点和备用节点不能相同", + "provide_the_remote_deployment_archive_url_887e4ef2": "请提供远程部署包地址", + "querying_next_migration_batch_migrated_d_d_c481c987": "正在查询待迁移对象批次,当前已完成迁移: %d/%d", + "rebuild_file_storage_statistics_fe52d825": "重算文件存储统计", + "rebuild_w_upload_stats_from_active_w_uploads_08023859": "根据当前 w_uploads 活跃记录全量重建 w_upload_stats(总量、类型、分类、趋势)", + "rebuilding_file_storage_statistics_active_records_d_60a4199b": "开始重算文件存储统计,活跃记录数: %d", + "receiving_this_message_confirms_that_the_notification_channel_79c41407": "当您收到这条消息,说明当前渠道连通性测试通过。", + "recipient_b013dc37": "目标接收者", + "recipient_email_to_88e59c15": "接收邮箱 (To)", + "record_was_not_found_e4ba0386": "记录不存在", + "recover_recovered_expired_source_lease_source_id_d_c4a7c01f": "[recover] 已恢复过期来源租约:source_id=%d", + "registration_failed_please_try_again_later_a4807a5d": "注册失败,请稍后再试", + "registration_is_disabled_by_the_administrator_420d53e6": "管理员关闭了注册", + "remote_addr_must_not_be_empty_ba45da61": "remote_addr 不能为空", + "remote_source_redirect_limit_exceeded_3fdd16bf": "远程来源重定向次数超过限制", + "remote_url_sources_do_not_support_update_checks_1ecca79c": "远程地址来源不支持检查更新,请使用立即同步", + "remote_url_sources_must_not_include_github_or_48c0b153": "远程地址来源不能包含 GitHub 或自动更新配置", + "renew_a_single_specified_acme_certificate_a27fd4e1": "对单个指定的 ACME 证书执行续期", + "renewal_certificate_data_saved_9d25aae9": "【续签任务】证书数据存储完成!", + "renewal_certificate_issued_securely_storing_certificate_and_private_cfaa1b37": "【续签任务】证书签发成功,正在将证书内容与私钥安全写入数据库...", + "renewal_decrypting_dns_account_credentials_and_acme_account_6ac3acd9": "【续签任务】正在解密 DNS 账号凭据及 ACME 账户私钥...", + "renewal_requested_domains_v_e4bf6bab": "【续签任务】待申请的域名列表: %v", + "renewal_requesting_ssl_certificate_issuance_through_the_acme_98e351ee": "【续签任务】正在调用 ACME 客户端(通过 DNS-01 挑战)发起 SSL 证书签发请求,请稍候...", + "renewal_resolving_acme_account_1bc0d71a": "【续签任务】正在解析 ACME 账户...", + "renewal_resolving_dns_account_information_id_d_89687757": "【续签任务】正在解析 DNS 账户信息 (ID=%d)...", + "renewal_saving_acme_account_changes_15275665": "【续签任务】正在保存 ACME 账户可能的变更...", + "renewal_starting_renewal_setting_application_status_to_applying_a167afb3": "【续签任务】开始续签,设置申请状态为 applying...", + "request_body_json_77cfb393": "请求体 (JSON)", + "request_url_65a1f546": "请求地址", + "reset_the_password_for_a_specified_account_ef0bf7e5": "重置指定账号密码", + "resolve_acquiring_source_task_ownership_8bb0e8c7": "[resolve] 正在获取来源执行权", + "resolve_another_source_task_is_running_skipping_task_a4e1db69": "[resolve] 已有来源任务正在执行,本次任务跳过", + "resolve_resolving_github_release_repo_s_asset_s_6915eea4": "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", + "resolve_source_configuration_changed_skipping_task_4b3e6282": "[resolve] 来源配置已变化,本次任务跳过", + "resolve_source_configuration_or_ownership_changed_skipping_task_d8b6d56a": "[resolve] 来源配置或执行权已变化,本次任务跳过", + "resolve_source_no_longer_exists_skipping_task_c4e25edd": "[resolve] 来源已不存在,本次任务跳过", + "result_must_be_success_warning_or_failed_7bca2806": "result 仅支持 success、warning 或 failed", + "retention_days_must_be_between_1_and_3650_3b6a649b": "retention_days 必须在 1 到 3650 之间", + "retention_days_must_be_between_1_and_90_371a59a7": "retention_days 必须在 1 到 90 之间", + "root_domain_still_contains_domains_delete_them_all_277bc1d6": "根域下仍有域名,请先删除全部域名", + "route_configuration_path_must_not_be_empty_f70cb124": "route config path 不能为空", + "route_s_w_0efcb499": "路由 %s %w", + "rule_graph_contains_multiple_start_nodes_e13cd7e2": "规则图包含多个开始节点", + "rule_graph_is_missing_a_start_node_1efe3116": "规则图缺少开始节点", + "rule_graph_must_contain_exactly_one_allow_node_a05ca0b3": "规则图必须恰好包含一个通过节点", + "rule_graph_must_contain_exactly_one_start_node_3140d98e": "规则图必须恰好包含一个开始节点", + "rule_graph_must_not_contain_cycles_fd6fe797": "规则图不能包含循环", + "rule_graph_must_not_contain_more_than_d_3289a255": "规则图边数不能超过 %d", + "rule_graph_must_not_contain_more_than_d_76688447": "规则图节点数不能超过 %d", + "rule_graph_must_not_exceed_256_kib_855948bb": "规则图大小不能超过 256 KiB", + "rule_graph_schema_version_must_be_d_38d452c4": "规则图 schema_version 必须为 %d", + "rule_was_updated_by_another_operation_reload_it_bba246a7": "规则已被其他操作更新,请重新加载", + "s_contains_an_empty_domain_name_e4a83686": "%s 包含空域名", + "s_contains_duplicate_domain_name_s_15451d4d": "%s 包含重复域名 %s", + "s_contains_invalid_characters_enter_valid_ip_addresses_59634931": "%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", + "s_contains_invalid_network_range_q_ce167d93": "%s 包含无效网段 %q", + "s_contains_unsupported_value_q_6208a8c5": "%s 包含不支持的值 %q", + "s_domain_name_must_be_lowercase_without_surrounding_59ed3d2c": "%s 域名必须为小写且不含首尾空格:%s", + "s_is_not_a_valid_ip_address_643806a5": "%s 不是合法 IP", + "s_is_not_a_valid_ip_range_4835d8bf": "%s 不是合法 IP 段", + "s_must_be_a_duration_with_a_unit_b97fceb4": "%s 格式必须为带单位的时长,例如 30m 或 5s", + "s_must_be_a_json_array_of_strings_dbcb2bd7": "%s 必须为 JSON 字符串数组", + "s_must_be_a_nonnegative_integer_0_means_efc94604": "%s 必须为大于等于 0 的整数(0 表示不限制)", + "s_must_be_a_nonnegative_integer_0bbc7814": "%s 必须为大于等于 0 的整数", + "s_must_be_a_whole_number_of_days_c0ac3325": "%s 必须为大于等于 1 的整数天", + "s_must_be_an_integer_between_1_and_3f9a159c": "%s 必须为 1~2048 的整数(MiB)", + "s_must_be_an_integer_greater_than_0_9553dcdf": "%s 必须为大于 0 的整数", + "s_must_be_an_integer_or_a_size_2e9fba45": "%s 格式必须为整数或带 k/m/g 单位的大小值", + "s_must_be_between_100_and_999_6b9e594f": "%s 必须在 100 到 999 之间", + "s_must_be_disabled_mmdb_ip_api_geojs_deba53da": "%s 仅支持 disabled、mmdb、ip-api、geojs、ipinfo", + "s_must_be_epoll_kqueue_poll_select_rtsig_3177cbf8": "%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", + "s_must_be_true_or_false_3a8153ff": "%s 必须为 true 或 false", + "s_must_contain_at_least_one_status_code_15395d65": "%s 至少包含一个状态码标签", + "s_must_contain_at_most_d_network_ranges_6b0b78c4": "%s 最多允许 %d 个网段", + "s_must_not_be_empty_2d5ac8f7": "%s 不能为空", + "s_must_not_be_empty_after_expansion_e814e9ef": "%s 展开后不能为空", + "s_must_not_contain_newlines_54742aef": "%s 不能包含换行", + "s_must_not_contain_newlines_or_tabs_ff4de73f": "%s 不能包含换行或制表符", + "s_must_not_exceed_d_4b586e57": "%s 不能大于 %d", + "s_must_not_exceed_d_bytes_256_kib_2f297bdb": "%s 长度不能超过 %d 字节(256 KiB)", + "s_must_use_a_format_such_as_16_1042e770": "%s 格式必须类似 \"16 16k\"", + "s_must_use_a_format_such_as_1_5a9d67e2": "%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", + "s_supports_at_most_d_domain_names_4f3d5128": "%s 最多支持 %d 个域名", + "scan_acme_certificates_nearing_expiration_and_renew_them_c53289c4": "扫描即将到期的 ACME 证书并触发自动续期", + "scan_due_sources_d_batch_d_ab93c95c": "[scan] 到期来源=%d,本批=%d", + "scan_sources_still_due_after_this_batch_d_da73edba": "[scan] 本批处理后仍到期来源=%d", + "scanning_certificates_due_for_renewal_7f9e9ca0": "开始扫描待续期证书", + "scanning_unused_uploads_cutoff_s_5c2fbb16": "开始扫描未使用上传文件,阈值: %s", + "schedule_was_not_found_6205508a": "定时任务不存在", + "seconds_00fe8dda": " 秒", + "select_a_cname_a_or_aaaa_record_type_80ac9137": "请选择 CNAME、A 或 AAAA 记录类型", + "select_a_file_to_upload_a3a97de2": "请选择要上传的文件", + "select_a_pages_deployment_source_type_70065e2c": "请选择 pages 部署源类型", + "select_a_valid_cloudflare_dns_account_97385483": "请选择有效的 Cloudflare DNS 账号", + "select_a_valid_edge_node_0fafe682": "请选择有效的边缘节点", + "select_a_valid_target_mode_9a0c6691": "请选择有效的指向方式", + "selected_certificate_was_not_found_3772e60e": "所选证书不存在", + "send_data_to_an_external_webhook_using_a_068df6b3": "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。", + "send_email_c268c1b1": "发送邮件", + "send_failed_v_a04ce62f": "发送失败: %v", + "send_notification_e70e3a00": "推送通知", + "send_system_email_asynchronously_63d68e86": "异步发送系统邮件", + "sequentially_convert_managed_images_to_webp_at_the_05d39459": "串行将文件管理中的图片转换为指定质量的 WebP 并写入永久缓存", + "serialize_289c64ad": "序列化", + "server_address_server_address_is_not_configured_configure_9e094296": "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试", + "server_url_must_not_be_empty_269fe8ad": "server_url 不能为空", + "signature_verification_key_from_the_feishu_group_bot_6090b176": "飞书群机器人安全设置中的签名校验 Key", + "signing_secret_optional_9dcdda03": "签名校验密钥 (Secret) (可选)", + "since_and_until_must_be_provided_together_4fdc9431": "since 与 until 需同时提供", + "since_must_be_an_rfc3339_timestamp_ad87e410": "since 必须为 RFC3339 时间", + "source_database_contains_no_logs_skipping_partition_precreation_c2753081": "源库无日志数据,跳过分区预建", + "spa_fallback_path_contains_unsupported_characters_14eecbe2": "spa fallback 回退路径包含不支持的字符", + "spa_fallback_path_must_not_contain_or_segments_eee02366": "spa fallback 回退路径不能包含 . 或 .. 路径段", + "spa_fallback_path_must_not_contain_whitespace_or_a7661241": "spa fallback 回退路径不能包含空白或控制字符", + "spa_fallback_path_must_not_exceed_512_characters_8be1ecf7": "spa fallback 回退路径长度不能超过 512", + "spa_fallback_path_must_point_to_a_specific_dff5905a": "spa fallback 回退路径必须指向具体文件", + "spa_fallback_path_must_start_with_91d66834": "spa fallback 回退路径必须以 / 开头", + "specified_version_was_not_found_s_eeec8487": "未找到指定版本: %s", + "sql_statement_must_not_be_empty_c3a52981": "SQL 语句不能为空", + "start_api_worker_and_scheduler_together_3e558997": "以融合模式同时启动 API、Worker 和 Scheduler", + "start_node_s_must_not_have_incoming_edges_0501404b": "开始节点 %s 不能有入边", + "starting_certificate_renewal_id_d_034783ec": "开始续期证书,ID: %d", + "starting_domain_synchronization_domain_s_zone_s_group_49f9cc95": "开始域名同步: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d", + "starting_log_database_migration_s_s_6a587927": "开始切换日志数据库:%s -> %s", + "starting_notification_event_s_channel_s_recipient_s_561117aa": "开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", + "starting_s_synchronization_name_s_id_d_target_ce5444ff": "开始%s同步: name=%s id=%d target=%s 域名数=%d", + "starting_sequential_compressed_image_cache_warmup_quality_s_ec690c4f": "开始串行预热图片压缩缓存,质量: %s,每批: %d", + "starting_storage_migration_s_s_total_objects_d_43cbe32d": "开始存储迁移: %s -> %s,总对象数: %d", + "status_code_must_be_an_integer_between_100_3328ab45": "status_code 必须为 100-599 之间的整数", + "status_code_must_be_between_d_and_d_f8229da8": "状态码须在 %d–%d: %s", + "status_code_range_endpoints_are_reversed_s_afcfd994": "状态码区间左右端点反序: %s", + "status_code_tag_must_not_be_empty_21235ebf": "状态码标签不能为空", + "storage_configuration_updated_active_storage_remains_s_917066ca": "存储配置已更新,活动存储保持为 %s", + "storage_configuration_updated_directly_failed_migration_task_marked_c9d7148d": "存储配置直接更新,故障迁移任务自动标记为已解决", + "storage_connectivity_4038ce91": "存储连通性", + "storage_connectivity_test_failed_w_08dabbf5": "存储连通性测试失败: %w", + "storage_migration_completed_migrated_d_objects_active_storage_332fb1de": "存储迁移完成,共迁移 %d 个对象,活动存储已切换为 %s", + "storage_migration_maintenance_is_active_files_are_read_4bf9e57d": "存储迁移维护中,当前仅允许读取文件", + "subject_line_of_the_email_to_send_c3aa0753": "发送邮件的主题标题", + "subscription_content_must_not_exceed_d_bytes_f40541e2": "订阅内容不能超过 %d 字节", + "subscription_returned_status_code_d_2c185ec6": "订阅返回状态码 %d", + "subscription_url_must_use_http_or_https_e43cbb99": "订阅 URL 仅支持 http 或 https", + "succeeded_053461ce": "成功", + "switch_group_primary_and_standby_nodes_based_on_b2d78b3f": "根据节点在线和应用状态切换分组主备节点", + "switch_log_database_da446b57": "切换日志数据库", + "synchronization_completed_d_ip_addresses_or_ranges_7b645ca2": "同步成功,共 %d 条 IP/IP 段", + "synchronization_succeeded_bca900e9": "同步成功", + "synchronize_all_domains_currently_targeting_the_specified_node_3488196a": "同步当前指向指定节点的全部域名", + "synchronize_all_domains_in_a_target_group_b2397009": "同步指向分组内全部域名", + "synchronize_automatic_and_subscription_waf_ip_groups_that_ac3d686e": "同步到期的自动规则与订阅类型 WAF IP 组", + "synchronize_cloudflare_dns_records_for_a_single_domain_a06ce84b": "同步单个域名的 Cloudflare DNS 记录", + "synchronize_enabled_proxy_routes_with_uptime_kuma_monitors_8f761ebe": "将启用的代理规则同步到 Uptime Kuma 监控", + "synchronizing_proxy_routes_with_uptime_kuma_2d26b523": "开始同步代理规则到 Uptime Kuma", + "synchronizing_waf_ip_groups_due_for_an_update_e96ea0b3": "开始同步到期的 WAF IP 组", + "system_5b50d7c4": "系统", + "system_channel_test_415dd03a": "系统通道测试", + "system_cleanup_6c08e88d": "系统垃圾清理", + "system_cleanup_completed_removed_d_d_unused_uploads_5eca4159": "系统清理完成。成功清理未使用的上传文件 %d/%d 个;清理历史推送审计日志 %d 条;清理任务执行日志 %d 条。", + "system_configuration_was_not_found_00210cae": "系统配置不存在", + "system_manual_retry_created_and_enqueued_original_task_3c9cbd5d": "[系统] 手动触发重试,已重新创建任务并入队 (原任务ID: %s, 重试次数: %d/%d)", + "system_notification_94b7513e": "系统通知", + "system_reserved_file_types_cannot_be_managed_through_9369ffe7": "系统保留的文件类型不能通过通用文件接口操作", + "system_smtp_configuration_is_incomplete_291bb369": "系统 SMTP 邮件服务配置不完整", + "system_starting_asynchronous_task_name_s_type_s_90c6e5e2": "[系统] 开始执行异步任务 [名称: %s, 类型: %s],重试次数: %d/%d", + "system_starting_asynchronous_task_type_s_caca3e5b": "[系统] 开始执行异步任务 [类型: %s]", + "system_task_enqueued_waiting_for_execution_queue_s_86a50bcc": "[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)", + "system_task_failed_duration_d_ms_error_v_63653548": "[系统] 任务执行失败,耗时: %d ms,错误原因: %v", + "system_task_succeeded_duration_d_ms_result_s_161aa7ca": "[系统] 任务执行成功,耗时: %d ms,执行结果: %s", + "target_group_is_disabled_08c76979": "指向分组已停用", + "target_group_must_differ_from_the_current_group_2702a3d3": "目标分组不能为当前分组", + "target_group_was_not_found_71c24083": "目标分组不存在", + "target_log_database_febc306d": "目标日志库", + "target_log_database_is_already_active_no_migration_92ab4313": "目标日志库与当前日志库相同,无需迁移", + "target_storage_configuration_json_884c1a08": "目标存储配置 (JSON)", + "task_cannot_continue_53c9c59a": "任务无法继续执行", + "task_completed_0b4c0784": "任务完成: ", + "task_execution_was_not_found_fc632d27": "任务执行记录不存在", + "task_execution_was_not_found_w_284e6bfc": "任务执行记录不存在: %w", + "task_parameters_must_not_be_empty_bdc11114": "任务参数不能为空", + "telegram_bot_b0edcbcc": "Telegram 机器人", + "template_content_must_not_be_empty_6fce7653": "模板内容不能为空", + "template_identifier_already_exists_93895c12": "模板标识符已存在", + "template_identifier_must_not_be_empty_7a1e41d3": "模板标识符不能为空", + "template_name_must_not_be_empty_da00e8dc": "模板名称不能为空", + "template_s_was_not_found_or_is_unavailable_70f4fa4e": "模板 %s 不存在或不可用: %w", + "template_was_not_found_37b0cea2": "模板不存在", + "terminal_node_s_must_not_have_outgoing_edges_8ece7ac6": "终止节点 %s 不能有出口", + "the_active_node_requires_a_valid_ipv4_address_edf71cfe": "生效节点必须配置合法 IPv4", + "the_active_pages_deployment_cannot_be_deleted_9f0bb393": "不能删除当前激活的 Pages 部署", + "the_confirmed_version_has_changed_refresh_and_confirm_8a40d0fc": "确认的版本已变化,请刷新后重新确认", + "the_current_rules_have_not_changed_and_cannot_c8b88f7d": "当前规则没有变更,不能重复发布", + "the_currently_signed_in_user_cannot_be_deleted_e47df151": "不能删除当前登录用户", + "the_log_database_is_clickhouse_but_clickhouse_is_bc5e49a0": "当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse", + "the_log_database_is_postgresql_but_postgresql_is_01f3b8d3": "当前日志主库为 PostgreSQL 但 PostgreSQL 未启用(当前为 SQLite 主库)。请运行『切换日志数据库』迁回 SQLite 或启用 PostgreSQL", + "the_log_database_is_sqlite_but_postgresql_is_8cfdfdaa": "当前日志主库为 SQLite 但当前主库为 PostgreSQL。请运行『切换日志数据库』迁移到 PostgreSQL", + "the_selected_version_is_not_a_preview_release_15d2f434": "指定版本不是 preview 发布", + "the_specified_entry_file_does_not_exist_in_f029c906": "当前激活部署中不存在指定入口文件", + "the_specified_uploaded_asset_was_not_found_in_942c8d4d": "GitHub Release 中未找到指定的已上传 asset", + "this_access_token_has_no_administrator_privileges_and_9005f2b0": "该访问令牌没有管理员权限,无法访问管理端点", + "this_account_has_no_email_address_contact_an_4e504171": "该账号未绑定邮箱,请联系管理员绑定邮箱后再登录", + "this_account_has_no_email_address_use_a_e29bb819": "该账号未绑定邮箱,使用临时码登录", + "this_configuration_is_managed_by_system_tasks_and_1930e327": "该配置项由系统任务管理,禁止手动修改", + "this_dns_account_is_used_by_certificates_and_54842b64": "该 DNS 账号已被证书使用,无法删除", + "this_domain_already_belongs_to_another_target_group_1f6c5889": "该域名已加入其他指向分组", + "this_email_address_is_bound_to_another_account_48d6a2fa": "该邮箱已被其他账号绑定", + "this_endpoint_does_not_allow_access_token_authentication_93ced189": "该端点不允许使用访问令牌进行身份验证", + "this_external_account_is_bound_to_another_user_029fce16": "该外部账号已绑定到其他用户", + "this_is_a_system_notification_channel_connectivity_test_cbd9d668": "这是一条来自系统的消息通道连通性测试消息。", + "this_node_is_not_a_tunnelclient_0a666f7a": "此节点不是 TunnelClient 类型", + "this_node_is_not_a_tunnelrelay_49925484": "此节点不是 TunnelRelay 类型", + "this_origin_is_still_referenced_by_rules_and_fad589f1": "该源站仍被规则引用,无法删除", + "this_task_cannot_be_retried_be95f1db": "该任务不支持重试", + "time_range_must_not_exceed_30_days_93f190e7": "时间范围不能超过 30 天", + "to_subject_and_body_must_not_be_empty_614c8b17": "to、subject、body 不能为空", + "token_name_must_not_be_empty_cd7ba9f4": "令牌名称不能为空", + "token_was_not_found_or_access_is_not_b430e116": "令牌不存在或无权操作", + "too_many_login_attempts_try_again_later_3245d6d9": "登录尝试过于频繁,请稍后再试", + "too_many_login_requests_please_try_again_later_4bcd0be6": "登录请求过于频繁,请稍后再试", + "transfer_uploading_file_to_target_storage_s_size_8118b17d": "[传输中] 正在向目标存储上传文件: %s (大小: %d 字节, 类型: %s)", + "triggered_when_an_administrator_signs_in_successfully_0ff0cfc5": "当管理员成功登录系统时触发此通知", + "tunnel_token_must_not_be_empty_67a53536": "tunnel_token 不能为空", + "unable_to_compile_configuration_for_node_s_w_66b38ddb": "节点 %s 的配置无法编译: %w", + "unable_to_create_database_export_file_c78edf48": "无法创建数据库导出文件", + "unable_to_enqueue_cloudflare_synchronization_task_4fc208da": "无法投递 Cloudflare 同步任务", + "unable_to_export_database_9f34252c": "无法导出数据库", + "unable_to_generate_an_available_username_dec720c4": "无法生成可用用户名", + "unable_to_get_the_currently_active_configuration_version_6a36cf1a": "无法获取当前激活的配置版本:%s", + "unable_to_get_the_currently_active_configuration_version_c8a24858": "无法获取当前激活的配置版本:%w", + "unable_to_get_username_from_authentication_source_973bd1db": "无法从认证源获取用户名", + "unable_to_identify_the_current_user_9a5f999b": "无法识别当前用户", + "unable_to_open_database_file_df3e8a7f": "无法打开数据库文件: ", + "unable_to_read_database_file_information_52111f56": "无法读取数据库文件信息: ", + "unable_to_save_session_please_try_again_58acb751": "无法保存会话信息,请重试", + "unable_to_serialize_rule_graph_w_48c45bb9": "规则图无法序列化: %w", + "unauthorized_access_f0d65927": "未经授权访问", + "unknown_4d8c1c5b": "未知", + "unknown_log_database_configuration_s_98368584": "未知的日志主库配置: %s", + "unknown_node_type_s_332fe973": "未知节点类型 %s", + "unknown_task_type_s_b04735f2": "未知的任务类型: %s", + "unregistered_task_handler_s_271644ad": "未注册的任务处理器: %s", + "unsupported_pages_deployment_source_type_5b4321a7": "pages 部署源类型不受支持", + "unsupported_verification_scenario_e2264302": "不支持的验证场景", + "until_must_be_an_rfc3339_timestamp_ce5dee46": "until 必须为 RFC3339 时间", + "until_must_be_later_than_since_a541175c": "until 必须晚于 since", + "update_archive_size_mismatch_got_d_want_d_195807e8": "升级归档大小不匹配: got %d, want %d", + "upstream_response_s_a987ca9f": "上游返回: %s", + "uptime_kuma_address_is_required_when_the_integration_2a83e4ea": "启用 Uptime Kuma 时地址不能为空", + "uptime_kuma_address_must_start_with_http_or_87425cf2": "uptime Kuma 地址必须以 http:// 或 https:// 开头", + "uptime_kuma_integration_is_disabled_skipping_execution_30faa46e": "Uptime Kuma 集成未启用,跳过执行", + "uptime_kuma_password_is_required_when_the_integration_6eed6c4c": "启用 Uptime Kuma 时密码不能为空", + "uptime_kuma_synchronization_completed_dfb5cd80": "Uptime Kuma 同步完成", + "uptime_kuma_synchronization_failed_v_0f0e24be": "Uptime Kuma 同步失败: %v", + "uptime_kuma_synchronization_is_already_running_skipping_this_3e44a54f": "Uptime Kuma 同步任务正在执行,跳过本次调度", + "uptime_kuma_username_is_required_when_the_integration_f43559eb": "启用 Uptime Kuma 时用户名不能为空", + "user_id_is_required_5f1165d8": "用户ID必填", + "user_id_must_not_be_empty_07bc47a7": "用户 ID 不能为空", + "user_was_not_found_8a2ea403": "未找到该用户", + "user_was_not_found_a82de572": "用户不存在", + "username_already_exists_d1ee5409": "用户名已存在", + "username_must_not_be_empty_390ccdec": "用户名不能为空", + "username_must_not_be_empty_fb128ac0": "用户名不能为空\n", + "username_of_the_account_whose_password_will_be_29c265c9": "重置密码的目标用户名", + "username_or_email_address_is_already_in_use_18fa1479": "用户名或邮箱已被占用", + "username_s_264d6306": "用户名: %s\n", + "validate_154723a1": "验证", + "verification_checking_target_file_consistency_sha_256_s_daef3bb0": "[校验中] 正在对目标文件进行数据一致性校验 (SHA-256): %s", + "verification_code_is_incorrect_or_expired_62fb7904": "验证码错误或已过期", + "verification_codes_are_being_requested_too_frequently_try_84835f1c": "验证码发送频繁,请稍后再试", + "verified_file_consistency_check_succeeded_s_09148b16": "[校验通过] 文件一致性校验成功: %s", + "verify_verifying_archive_structure_and_entry_file_dcc5d692": "[verify] 正在校验归档结构与入口文件", + "verify_verifying_github_release_archive_and_entry_file_a00c1f16": "[verify] 正在校验 GitHub Release 归档与入口", + "version_identifier_conflict_please_try_again_c81c8dad": "版本号生成冲突,请重试", + "version_must_not_be_empty_09fabcb3": "version 不能为空", + "videos_c20f7618": "视频", + "waf_ip_group_snapshot_size_d_bytes_exceeds_4bdc932a": "WAF IP 组快照大小 %d 字节超过上限 %d 字节", + "waf_ip_group_synchronization_completed_8c799689": "WAF IP 组同步完成", + "waf_ip_group_synchronization_failed_v_3524af2d": "WAF IP 组同步失败: %v", + "waf_rule_group_d_was_not_found_4e2d4084": "WAF 规则组 %d 不存在", + "waf_rule_name_must_not_be_empty_92c1a213": "WAF 规则名称不能为空", + "waf_rule_operation_failed_4e01c228": "WAF 规则操作失败", + "waf_rule_was_not_found_d3a18a2e": "WAF 规则不存在", + "waiting_for_image_cache_warmup_lock_quality_s_88fe5819": "等待获取图片缓存预热执行锁,质量: %s", + "warm_up_compressed_image_cache_bd2f3c3f": "预热图片压缩缓存", + "warning_source_file_is_missing_marking_it_deleted_552b422a": "警告: 源存储中物理文件不存在,标记为已删除并跳过: %s (错误: %v)", + "webhook_url_658c6265": "Webhook 地址", + "webp_compression_quality_must_be_low_medium_or_866b5127": "WebP 压缩质量,仅支持 low、medium、high", + "wildcard_domain_names_are_not_supported_e40045cc": "不支持通配符域名", + "years_cda14918": " 年 ", + "zone_must_be_a_valid_registered_root_domain_cc7d1d33": "zone 必须是有效的注册根域", + "zone_was_not_found_86db3707": "Zone 不存在", + "public_error_a7274b96774b": "请填写通道名称", + "public_error_9f24cd1da554": "通道名称只能包含英文字母、数字和下划线", + "public_error_1b139d2dcf79": "请填写请求 URL 或地址", + "public_error_88df46747841": "为确保安全,请求 URL 必须使用 HTTPS 协议", + "public_error_29d0f4706829": "请填写请求体模板", + "public_error_2127b11c4518": "请填写 Telegram 机器人 Token", + "public_error_22bcab9337f1": "请求体模板必须是有效的 JSON 格式", + "public_error_7c511ae7174a": "尚未配置人机验证", + "public_error_71759e624238": "会话上下文无效", + "public_error_b9cbe3209e14": "OAuth 状态与当前会话不匹配", + "public_error_ff5184f57c49": "OAuth 绑定用户与当前会话不匹配", + "public_error_b5121d590135": "ID 无效", + "public_error_519c7fed477a": "通道 ID 无效", + "public_error_190df01d2076": "通道不存在", + "public_error_cbd9709fbd01": "此通知事件已配置", + "public_error_b93a32137fc1": "自定义模板必须是有效的 JSON 格式", + "public_error_add0341f2152": "请先配置至少一个推送通道,再启用事件", + "public_error_e59e34f32d9f": "通道名称已存在", + "public_error_cdb14a9d218e": "不支持的任务类型", + "public_error_02fbf8eef643": "请提供 event_key 或 task_type", + "public_error_7f64283d20ea": "不支持的内置事件标识", + "public_error_eac68fcd302d": "事件 ID 无效", + "public_error_c854c958bd9c": "通知事件不存在", + "public_error_a37526e5d726": "自动注册重试凭证无效", + "public_error_452209a49676": "自动注册凭证已更换,请使用分配的 Agent Token", + "public_error_da739b170c48": "节点列表查询参数无效", + "public_error_79e7e0ad56c0": "无法加载节点" +} diff --git a/pkg/protocol/waf_ip_group_snapshot.go b/pkg/protocol/waf_ip_group_snapshot.go index 6cb23a06..ce364096 100644 --- a/pkg/protocol/waf_ip_group_snapshot.go +++ b/pkg/protocol/waf_ip_group_snapshot.go @@ -33,7 +33,7 @@ func ValidateWAFIPGroupSnapshotSize(groups map[string]WAFIPGroup) error { return err } if len(data) > MaxWAFIPGroupSnapshotBytes { - return fmt.Errorf("WAF IP 组快照大小 %d 字节超过上限 %d 字节", len(data), MaxWAFIPGroupSnapshotBytes) + return fmt.Errorf("WAF IP group snapshot size %d bytes exceeds the limit of %d bytes", len(data), MaxWAFIPGroupSnapshotBytes) } return nil } diff --git a/pkg/push/push.go b/pkg/push/push.go index e035d62a..ab485b23 100644 --- a/pkg/push/push.go +++ b/pkg/push/push.go @@ -12,7 +12,7 @@ import ( ) const ( - defaultTitle = "系统通知" + defaultTitle = "System notification" levelInfo = "INFO" defaultHTTPClientTimeout = 10 * time.Second ) diff --git a/pkg/render/openresty/render.go b/pkg/render/openresty/render.go index a8f7ee1e..a3b394a6 100644 --- a/pkg/render/openresty/render.go +++ b/pkg/render/openresty/render.go @@ -83,11 +83,11 @@ func RenderMainConfig(doc Document) string { func ValidateMainConfigTemplate(templateText string) error { trimmed := strings.TrimSpace(templateText) if trimmed == "" { - return errors.New("OpenRestyMainConfigTemplate 不能为空") + return errors.New("OpenRestyMainConfigTemplate must not be empty") } for _, placeholder := range requiredMainConfigTemplatePlaceholders { if !strings.Contains(trimmed, placeholder) { - return fmt.Errorf("OpenRestyMainConfigTemplate 必须保留占位符 %s", placeholder) + return fmt.Errorf("OpenRestyMainConfigTemplate must retain placeholder %s", placeholder) } } return nil diff --git a/pkg/render/openresty/render_route.go b/pkg/render/openresty/render_route.go index 273d7965..b63af131 100644 --- a/pkg/render/openresty/render_route.go +++ b/pkg/render/openresty/render_route.go @@ -117,7 +117,7 @@ func renderPagesRoute(builder *strings.Builder, route Route, displayName, server certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs) domainCertIDs := route.DomainCertIDs if len(certIDs) == 0 { - return fmt.Errorf("路由 %s 未配置证书", route.SiteName) + return fmt.Errorf("no certificate is configured for route %s", route.SiteName) } partition := partitionRouteDomainsByCert(normalizedRouteDomains(route), certIDs, domainCertIDs) if err := validateRouteCertificates(route, displayName, certIDs, partition, certificates); err != nil { @@ -143,7 +143,7 @@ func renderProxyRoute(builder *strings.Builder, route Route, displayName, server certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs) domainCertIDs := route.DomainCertIDs if len(certIDs) == 0 { - return fmt.Errorf("路由 %s 未配置证书", route.SiteName) + return fmt.Errorf("no certificate is configured for route %s", route.SiteName) } partition := partitionRouteDomainsByCert(normalizedRouteDomains(route), certIDs, domainCertIDs) if err := validateRouteCertificates(route, displayName, certIDs, partition, certificates); err != nil { diff --git a/pkg/render/openresty/service_worker.go b/pkg/render/openresty/service_worker.go index 2b3cfb8f..02021e12 100644 --- a/pkg/render/openresty/service_worker.go +++ b/pkg/render/openresty/service_worker.go @@ -20,11 +20,11 @@ const ( // DefaultSWOfflineHTML is the built-in contact page shown when the domain is blocked. const DefaultSWOfflineHTML = ` - + -网站暂时无法访问 | 联系站长 +Website unavailable | Contact the administrator -

    网站暂时无法访问

    -

    当前域名暂时无法从网络访问。请通过其他方式联系网站管理员获取最新访问入口。

    +

    Website temporarily unavailable

    +

    This domain is temporarily unreachable. Contact the website administrator through another channel for the latest access address.

    ` diff --git a/pkg/render/openresty/status_codes.go b/pkg/render/openresty/status_codes.go index f97381aa..d5e736d3 100644 --- a/pkg/render/openresty/status_codes.go +++ b/pkg/render/openresty/status_codes.go @@ -23,29 +23,29 @@ const ( func ParseStatusCodeTag(tag string) (lo, hi int, err error) { tag = strings.TrimSpace(tag) if tag == "" { - return 0, 0, errors.New("状态码标签不能为空") + return 0, 0, errors.New("status code tag must not be empty") } if before, after, ok := strings.Cut(tag, "-"); ok { lo, err = strconv.Atoi(before) if err != nil { - return 0, 0, fmt.Errorf("无效状态码区间: %s", tag) + return 0, 0, fmt.Errorf("invalid status code range: %s", tag) } hi, err = strconv.Atoi(after) if err != nil { - return 0, 0, fmt.Errorf("无效状态码区间: %s", tag) + return 0, 0, fmt.Errorf("invalid status code range: %s", tag) } } else { lo, err = strconv.Atoi(tag) if err != nil { - return 0, 0, fmt.Errorf("无效状态码: %s", tag) + return 0, 0, fmt.Errorf("invalid status code: %s", tag) } hi = lo } if lo > hi { - return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag) + return 0, 0, fmt.Errorf("status code range endpoints are reversed: %s", tag) } if lo < StatusCodeMin || hi > StatusCodeMax { - return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag) + return 0, 0, fmt.Errorf("status code must be between %d and %d: %s", StatusCodeMin, StatusCodeMax, tag) } return lo, hi, nil } diff --git a/pkg/util/format.go b/pkg/util/format.go index 5b18daba..2d5f6c87 100644 --- a/pkg/util/format.go +++ b/pkg/util/format.go @@ -43,28 +43,35 @@ func Bytes2Size(num int64) string { return numStr + " " + unit } -// Seconds2Time converts a number of seconds to a human-readable Chinese duration string. +// Seconds2Time converts a number of seconds to a human-readable English duration string. func Seconds2Time(num int) (time string) { if num/secondsPerYear > 0 { - time += strconv.Itoa(num/secondsPerYear) + " 年 " + time += durationPart(num/secondsPerYear, "year") + " " num %= secondsPerYear } if num/secondsPerMonth > 0 { - time += strconv.Itoa(num/secondsPerMonth) + " 个月 " + time += durationPart(num/secondsPerMonth, "month") + " " num %= secondsPerMonth } if num/secondsPerDay > 0 { - time += strconv.Itoa(num/secondsPerDay) + " 天 " + time += durationPart(num/secondsPerDay, "day") + " " num %= secondsPerDay } if num/secondsPerHour > 0 { - time += strconv.Itoa(num/secondsPerHour) + " 小时 " + time += durationPart(num/secondsPerHour, "hour") + " " num %= secondsPerHour } if num/secondsPerMinute > 0 { - time += strconv.Itoa(num/secondsPerMinute) + " 分钟 " + time += durationPart(num/secondsPerMinute, "minute") + " " num %= secondsPerMinute } - time += strconv.Itoa(num) + " 秒" + time += durationPart(num, "second") return } + +func durationPart(value int, unit string) string { + if value != 1 { + unit += "s" + } + return strconv.Itoa(value) + " " + unit +} diff --git a/pkg/util/format_test.go b/pkg/util/format_test.go index b2fd973f..50395df9 100644 --- a/pkg/util/format_test.go +++ b/pkg/util/format_test.go @@ -35,12 +35,12 @@ func TestSeconds2Time(t *testing.T) { input int expected string }{ - {0, "0 秒"}, - {30, "30 秒"}, - {60, "1 分钟 0 秒"}, - {125, "2 分钟 5 秒"}, - {3600, "1 小时 0 秒"}, - {86400, "1 天 0 秒"}, + {0, "0 seconds"}, + {30, "30 seconds"}, + {60, "1 minute 0 seconds"}, + {125, "2 minutes 5 seconds"}, + {3600, "1 hour 0 seconds"}, + {86400, "1 day 0 seconds"}, } for _, tt := range tests { diff --git a/scripts/audit-non-english.mjs b/scripts/audit-non-english.mjs new file mode 100644 index 00000000..b9d88a41 --- /dev/null +++ b/scripts/audit-non-english.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +// Read-only inventory of all non-ASCII text in tracked and unignored files. +import { execFileSync } from 'node:child_process'; +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, extname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const output = resolve(root, process.argv[2] ?? '.codex-artifacts/non-english-audit'); +const paths = [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 16 * 1024 * 1024 }).toString('utf8').split('\0').filter(Boolean))].sort(); +const scripts = ['Han', 'Hiragana', 'Katakana', 'Hangul', 'Cyrillic', 'Arabic', 'Hebrew', 'Greek', 'Thai', 'Devanagari', 'Latin']; +const scriptPatterns = scripts.map((name) => [name, new RegExp(`\\p{Script=${name}}`, 'u')]); +const nonASCII = /[^\x00-\x7f]/gu; +const escapes = /\\u\{([0-9a-f]{1,6})\}|\\u([0-9a-f]{4})|\\U([0-9a-f]{8})/gi; +const records = []; +const skipped = []; +let scanned = 0; + +function component(path) { + const parts = path.split('/'); + if (path.startsWith('frontend/app/')) return parts.slice(0, 5).join('/'); + if (path.startsWith('internal/apps/')) return parts.slice(0, 3).join('/'); + return parts.slice(0, Math.min(3, parts.length - 1)).join('/') || '.'; +} + +// Comments are classified lexically; ambiguous source remains a review candidate. +function commentMask(text, path) { + const extension = extname(path); + const mask = new Uint8Array(text.length); + const slashComments = ['.go', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.css', '.scss', '.proto'].includes(extension); + const hashComments = ['.sh', '.yaml', '.yml', '.toml', '.py', '.conf'].includes(extension) || /(^|\/)(\.env[^/]*|\.gitignore|Dockerfile|Makefile)$/.test(path); + let state = '', quote = ''; + for (let i = 0; i < text.length; i++) { + const c = text[i], next = text[i + 1]; + if (state === 'line') { + mask[i] = 1; + if (c === '\n') state = ''; + } else if (state === 'block') { + mask[i] = 1; + if (c === '*' && next === '/') { mask[++i] = 1; state = ''; } + } else if (quote) { + if (c === '\\' && !(extension === '.go' && quote === '`')) i++; + else if (c === quote) quote = ''; + } else if (slashComments && c === '/' && next === '/') { + mask[i] = mask[++i] = 1; state = 'line'; + } else if (slashComments && c === '/' && next === '*') { + mask[i] = mask[++i] = 1; state = 'block'; + } else if (hashComments && c === '#') { + mask[i] = 1; state = 'line'; + } else if (c === '"' || c === "'" || (slashComments && c === '`')) quote = c; + } + return mask; +} + +function category(path, line, spans, mask, offset) { + if (path.startsWith('frontend/messages/') || /\/locales\//.test(path)) return 'translation-resource'; + if (/\.(md|mdx)$/i.test(path)) return 'documentation'; + if (['docs/docs.go', 'docs/swagger.json', 'docs/swagger.yaml'].includes(path)) return 'generated-api-documentation'; + if (path.startsWith('docs/')) return 'documentation-site'; + if (path.startsWith('.agents/') || path === 'scripts/audit-non-english.mjs') return 'developer-tooling'; + if (path.startsWith('.auto/')) return 'development-history'; + if (path.startsWith('.github/ISSUE_TEMPLATE/') || /close.ticket/i.test(path)) return 'community-template'; + if (/(_test\.go|\.(test|spec)\.[^.]+)$/.test(path) || /(^|\/)(tests?|testdata|testhelper)\//.test(path)) return 'test-fixture'; + if (path.startsWith('internal/infra/persistence/migrator/goose/')) return 'historical-migration'; + if (!spans.some((span) => /\p{L}/u.test(span.value))) return 'symbol-or-typography'; + if (spans.every((span) => mask[offset + span.column - 1])) return 'source-comment'; + if ((path === 'internal/apps/upload/stats/category.go' || path === 'frontend/app/(main)/admin/files/components/file-stats.tsx' || path === 'frontend/scripts/check-runtime-language.mjs') && spans.every((span) => /^[图片视频音频文档压缩包其他]+$/u.test(span.value))) return 'legacy-identifier'; + if (extname(path) === '.go' && /`[^`]*(?:json|gorm|description|example):/.test(line)) return 'schema-metadata'; + return 'review-candidate'; +} + +for (const path of paths) { + // Never include private environment values, even if accidentally tracked. + if (/(^|\/)\.env(?:\.|$)/.test(path) && !/(example|sample|template)/.test(path)) { skipped.push({ path, reason: 'private-environment' }); continue; } + let bytes; + try { bytes = readFileSync(resolve(root, path)); } catch (error) { skipped.push({ path, reason: error.code }); continue; } + let text; + try { + if (bytes[0] === 0xff && bytes[1] === 0xfe) text = new TextDecoder('utf-16le', { fatal: true }).decode(bytes); + else if (bytes[0] === 0xfe && bytes[1] === 0xff) text = new TextDecoder('utf-16be', { fatal: true }).decode(bytes); + else { + if (bytes.includes(0)) { skipped.push({ path, reason: 'binary' }); continue; } + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } + } catch { skipped.push({ path, reason: 'non-utf8-or-binary' }); continue; } + scanned++; + const mask = commentMask(text, path); + let offset = 0, lineNumber = 0; + for (const line of text.split('\n')) { + lineNumber++; + const spans = [...line.matchAll(nonASCII)].map((match) => ({ column: match.index + 1, value: match[0], escaped: false })); + for (const match of line.matchAll(escapes)) { + const code = parseInt(match[1] ?? match[2] ?? match[3], 16); + if (code > 127 && code <= 0x10ffff) spans.push({ column: match.index + 1, value: String.fromCodePoint(code), escaped: true }); + } + if (spans.length) { + spans.sort((a, b) => a.column - b.column); + const characters = [...new Set(spans.map((span) => span.value))]; + const names = scriptPatterns.filter(([, pattern]) => characters.some((c) => pattern.test(c))).map(([name]) => name); + if (characters.some((c) => /\p{L}/u.test(c) && !scriptPatterns.some(([, pattern]) => pattern.test(c)))) names.push('Other-letter'); + records.push({ path, line: lineNumber, component: component(path), category: category(path, line, spans, mask, offset), scripts: names, codepoints: characters.map((c) => `U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`), spans, text: line.replace(/\r$/, '') }); + } + offset += line.length + 1; + } + if (nonASCII.test(path)) records.push({ path, line: 0, component: component(path), category: 'non-ascii-path', scripts: [], codepoints: [], spans: [], text: path }); + nonASCII.lastIndex = 0; +} + +mkdirSync(output, { recursive: true }); +const totals = {}; +for (const record of records) totals[record.category] = (totals[record.category] ?? 0) + 1; +const candidates = records.filter((record) => record.category === 'review-candidate'); +const decisions = { + 'translation-resource': 'Keep in locale resources; expected localized content.', + documentation: 'Keep: project policy requires Chinese documentation.', + 'generated-api-documentation': 'Keep: generated Chinese API documentation, not runtime log or UI literals.', + 'documentation-site': 'Keep: Chinese documentation navigation, locale names and documentation assets.', + 'developer-tooling': 'Keep: agent instructions, examples and audit policy; outside application runtime.', + 'development-history': 'Keep: development history, not imported by application code.', + 'community-template': 'Keep: upstream Chinese community issue/message templates.', + 'test-fixture': 'Keep: test samples and expected Unicode behavior.', + 'historical-migration': 'Keep: migration history; do not rewrite applied migrations.', + 'source-comment': 'Keep: source comments do not produce runtime text.', + 'legacy-identifier': 'Keep: persisted upload category identifiers; display labels use translations.', + 'schema-metadata': 'Review separately: schema descriptions/examples, not runtime log messages.', + 'symbol-or-typography': 'Keep: punctuation, emoji, math and explicit Unicode/control-character handling.', + 'review-candidate': 'Review source: not covered by the classifications above.', +}; +const csv = (rows) => '\ufeff' + [['path', 'line', 'component', 'category', 'scripts', 'text'], ...rows.map((r) => [r.path, r.line, r.component, r.category, r.scripts.join(', '), r.text])].map((row) => row.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(',')).join('\r\n'); +writeFileSync(resolve(output, 'occurrences.json'), JSON.stringify({ coverage: { discovered: paths.length, scanned, skipped, columns: '1-based UTF-16 offsets', scope: 'git ls-files --cached --others --exclude-standard; ignored dependencies, build output and private environment files excluded; binary files are not OCRed or unpacked' }, totals, decisions, records }, null, 2) + '\n'); +writeFileSync(resolve(output, 'occurrences.csv'), csv(records)); +writeFileSync(resolve(output, 'review-candidates.csv'), csv(candidates)); +const groups = new Map(); +for (const r of candidates) groups.set(r.component, (groups.get(r.component) ?? 0) + 1); +const components = new Map(); +for (const r of records) { + if (!components.has(r.component)) components.set(r.component, { files: new Set(), lines: 0, categories: new Set() }); + const group = components.get(r.component); + group.files.add(r.path); + group.lines++; + group.categories.add(r.category); +} +const componentRows = [...components].sort((a, b) => b[1].lines - a[1].lines); +writeFileSync(resolve(output, 'components.json'), JSON.stringify(componentRows.map(([name, group]) => ({ component: name, files: [...group.files].sort(), lines: group.lines, categories: [...group.categories].sort() })), null, 2) + '\n'); +writeFileSync(resolve(output, 'summary.md'), [ + '# Non-English source inventory', '', + `Text files scanned: **${scanned}** of **${paths.length}** tracked and unignored files. Skipped: **${skipped.length}** (see occurrences.json).`, + `Non-ASCII or escaped Unicode lines: **${records.length}**, in **${new Set(records.map((r) => r.path)).size}** files. Review candidates: **${candidates.length}**.`, '', + 'All characters outside ASCII are included, including punctuation, symbols and emoji. Language cannot be inferred reliably from a script (Han may be Chinese or Japanese). Script names describe characters, not detected language.', '', + 'Classification is a review aid, not proof that a string executes or reaches a user. Comments, test data, migration history, schemas, community templates and legacy identifiers are retained. No source files are changed by this command.', '', + 'Ignored dependencies, generated build output and private environment files are excluded. Binary files (including screenshots, fonts, GeoIP data and archives) are listed as skipped; image text is not OCRed and archives are not unpacked by this command.', '', + '## Categories', '', '| Category | Lines | Disposition |', '| --- | ---: | --- |', ...Object.entries(totals).sort().map(([name, count]) => `| ${name} | ${count} | ${decisions[name] ?? 'Review'} |`), '', + '## Scripts', '', '| Script | Lines |', '| --- | ---: |', ...scripts.concat('Other-letter').map((script) => `| ${script} | ${records.filter((r) => r.scripts.includes(script)).length} |`), '', + '## Components requiring review', '', '| Component | Lines |', '| --- | ---: |', ...[...groups].sort((a, b) => b[1] - a[1]).map(([name, count]) => `| ${name} | ${count} |`), '', + '## Largest components (all categories)', '', '| Component | Lines | Files |', '| --- | ---: | ---: |', ...componentRows.slice(0, 30).map(([name, group]) => `| ${name} | ${group.lines} | ${group.files.size} |`), '', + '## Files', '', '- occurrences.csv: every matching line with its source path and component.', '- occurrences.json: exact character positions, Unicode code points, script classifications and skipped files.', '- components.json: every affected component, its source files, counts and categories.', '- review-candidates.csv: remaining source/configuration candidates, including embedded templates.', '', + 'Regenerate from the repository root: `node scripts/audit-non-english.mjs`.', '', +].join('\n')); +console.log(JSON.stringify({ scanned, skipped: skipped.length, lines: records.length, files: new Set(records.map((r) => r.path)).size, totals, output }, null, 2)); diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh index e565a492..cb9ac004 100755 --- a/scripts/install-agent.sh +++ b/scripts/install-agent.sh @@ -21,33 +21,33 @@ CURRENT_DIR="$(pwd)" LOG_FILE="${CURRENT_DIR}/install.log" # Translation text variables -TXT_DOCKER_RESTARTED="Docker 已启动/重启并运行中" -TXT_LOW_DOCKER_VERSION="Docker 版本较低,建议升级到 20.10 及以上版本" -TXT_INSTALL_DOCKER_CONFIRM="未检测到 Docker,是否现在安装?(y/n) [y]: " -TXT_DOCKER_INSTALL_ONLINE="开始在线安装 Docker..." -TXT_INSTALL_DOCKER_ONLINE="使用 opkg 在线安装 Docker..." -TXT_CHOOSE_LOWEST_LATENCY_SOURCE="选择延迟最低的镜像源:" -TXT_CHOOSE_LOWEST_LATENCY_DELAY="延迟为:" -TXT_TRY_NEXT_LINK="尝试使用链接:" -TXT_DOWNLOAD_DOCKER_SCRIPT="下载 Docker 安装脚本..." -TXT_DOWNLOAD_DOCKER_SCRIPT_SUCCESS="下载成功:" -TXT_SUCCESSFULLY_MESSAGE="开始执行安装..." -TXT_DOWNLOAD_FAIELD="下载失败:" -TXT_ALL_DOWNLOAD_ATTEMPTS_FAILED="所有 Docker 下载尝试均失败,请手动安装 Docker" -TXT_DOCKER_INSTALL_FAIL="Docker 安装失败" -TXT_DOCKER_INSTALL_SUCCESS="Docker 安装成功" -TXT_CANNOT_SELECT_SOURCE="无法选择合适的镜像源" -TXT_REGIONS_OTHER_THAN_CHINA="检测到非中国大陆地区,使用官方源安装 Docker..." -TXT_DOCKER_START_NOTICE="正在启动 Docker 服务..." -TXT_CANCEL_INSTALL_DOCKER="已取消 Docker 安装" -TXT_INVALID_YN_INPUT="无效的输入,请输入 y 或 n" +TXT_DOCKER_RESTARTED="Docker is running after startup/restart" +TXT_LOW_DOCKER_VERSION="Docker is outdated; version 20.10 or later is recommended" +TXT_INSTALL_DOCKER_CONFIRM="Docker was not found. Install it now? (y/n) [y]: " +TXT_DOCKER_INSTALL_ONLINE="Starting Docker installation..." +TXT_INSTALL_DOCKER_ONLINE="Installing Docker with opkg..." +TXT_CHOOSE_LOWEST_LATENCY_SOURCE="Selecting the mirror with the lowest latency:" +TXT_CHOOSE_LOWEST_LATENCY_DELAY="Latency:" +TXT_TRY_NEXT_LINK="Trying URL:" +TXT_DOWNLOAD_DOCKER_SCRIPT="Downloading the Docker installation script..." +TXT_DOWNLOAD_DOCKER_SCRIPT_SUCCESS="Download succeeded:" +TXT_SUCCESSFULLY_MESSAGE="Starting installation..." +TXT_DOWNLOAD_FAIELD="Download failed:" +TXT_ALL_DOWNLOAD_ATTEMPTS_FAILED="All Docker download attempts failed; install Docker manually" +TXT_DOCKER_INSTALL_FAIL="Docker installation failed" +TXT_DOCKER_INSTALL_SUCCESS="Docker installed successfully" +TXT_CANNOT_SELECT_SOURCE="Could not select a suitable mirror" +TXT_REGIONS_OTHER_THAN_CHINA="Outside mainland China; installing Docker from the official source..." +TXT_DOCKER_START_NOTICE="Starting the Docker service..." +TXT_CANCEL_INSTALL_DOCKER="Docker installation canceled" +TXT_INVALID_YN_INPUT="Invalid input; enter y or n" log() { echo -e "[$(date +'%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" } configure_accelerator() { - log "配置 Docker 国内镜像加速源..." + log "Configuring Docker mirrors for mainland China..." local docker_config_folder="/etc/docker" if [[ ! -d "$docker_config_folder" ]]; then mkdir -p "$docker_config_folder" @@ -317,13 +317,12 @@ done if [[ "$HAS_ARGS" == "false" ]]; then echo "==================================================" - echo "欢迎使用 OpenFlare Agent 安装脚本" echo "Welcome to the OpenFlare Agent Installer" echo "==================================================" - echo "请选择安装方式 / Please choose installation method:" - echo " 1) Local (本地安装: 下载二进制运行,需要本地有 OpenResty)" - echo " 2) Docker (容器安装: 运行内置 OpenResty 的 Agent 容器)" - read -p "请输入序号 [1-2] (默认 1): " method_choice + echo "Please choose an installation method:" + echo " 1) Local (download and run the binary; requires a local OpenResty installation)" + echo " 2) Docker (run the Agent container with OpenResty included)" + read -p "Enter an option [1-2] (default: 1): " method_choice method_choice=${method_choice:-1} case "$method_choice" in 2) INSTALL_METHOD="docker" ;; @@ -336,20 +335,20 @@ if [[ "$HAS_ARGS" == "false" ]]; then # Prompt for Server URL while [[ -z "$SERVER_URL" ]]; do - read -p "请输入 OpenFlare Server 地址 (例如 http://127.0.0.1:3000): " SERVER_URL + read -p "Enter the OpenFlare Server URL (for example, http://127.0.0.1:3000): " SERVER_URL done # Prompt for Token while [[ -z "$DISCOVERY_TOKEN" && -z "$AGENT_TOKEN" ]]; do - echo "请选择认证 Token 类型 / Please choose token type:" - echo " 1) Discovery Token (自动注册凭证 - 适合新节点上线)" - echo " 2) Agent Token (专属接入凭证 - 适合预创建节点)" - read -p "请输入序号 [1-2] (默认 1): " token_type_choice + echo "Please choose a token type:" + echo " 1) Discovery Token (automatic registration credentials for new nodes)" + echo " 2) Agent Token (dedicated credentials for pre-created nodes)" + read -p "Enter an option [1-2] (default: 1): " token_type_choice token_type_choice=${token_type_choice:-1} if [[ "$token_type_choice" == "2" ]]; then - read -p "请输入 Agent Token: " AGENT_TOKEN + read -p "Enter the Agent Token: " AGENT_TOKEN else - read -p "请输入 Discovery Token: " DISCOVERY_TOKEN + read -p "Enter the Discovery Token: " DISCOVERY_TOKEN fi done else @@ -371,13 +370,13 @@ fi # Run Docker container installation if Docker method selected if [[ "$INSTALL_METHOD" == "docker" ]]; then - echo "拉取最新的 OpenFlare Agent 镜像..." + echo "Pulling the latest OpenFlare Agent image..." docker pull ghcr.io/rain-kl/openflare-agent:latest - echo "停止并移除旧的 openflare-agent 容器 (如果存在)..." + echo "Stopping and removing the existing openflare-agent container, if any..." docker rm -f openflare-agent 2>/dev/null || true - echo "启动 openflare-agent 容器..." + echo "Starting the openflare-agent container..." if [[ -n "$AGENT_TOKEN" ]]; then docker run -d --name openflare-agent --restart unless-stopped \ -p 80:80 -p 443:443/tcp -p 443:443/udp \ @@ -395,8 +394,8 @@ if [[ "$INSTALL_METHOD" == "docker" ]]; then fi echo "" - echo "OpenFlare Agent (Docker) 安装成功!" - echo "您可以执行以下命令查看运行日志:" + echo "OpenFlare Agent (Docker) installed successfully!" + echo "Run the following command to view runtime logs:" echo " docker logs -f openflare-agent" exit 0 fi @@ -406,7 +405,7 @@ if [[ -z "$OPENRESTY_PATH" ]]; then if command -v openresty >/dev/null 2>&1; then OPENRESTY_PATH="$(command -v openresty)" elif [[ "$HAS_ARGS" == "false" ]]; then - read -p "未在 PATH 中检测到 openresty,请输入 OpenResty 二进制路径: " OPENRESTY_PATH + read -p "OpenResty was not found in PATH. Enter the path to its binary: " OPENRESTY_PATH if [[ -z "$OPENRESTY_PATH" ]]; then echo "Error: OpenResty binary path is required for local installation." exit 1 diff --git a/scripts/uninstall-agent.sh b/scripts/uninstall-agent.sh index 8cf50d6b..cd86b2f8 100644 --- a/scripts/uninstall-agent.sh +++ b/scripts/uninstall-agent.sh @@ -56,13 +56,12 @@ done if [[ "$HAS_ARGS" == "false" ]]; then echo "==================================================" - echo "欢迎使用 OpenFlare Agent 卸载脚本" echo "Welcome to the OpenFlare Agent Uninstaller" echo "==================================================" - echo "请选择卸载方式 / Please choose uninstall method:" - echo " 1) Local (本地卸载: 停止服务并删除二进制及配置)" - echo " 2) Docker (容器卸载: 停止并删除 Docker 容器)" - read -p "请输入序号 [1-2] (默认 1): " method_choice + echo "Please choose an uninstall method:" + echo " 1) Local (stop the service and remove the binary and configuration)" + echo " 2) Docker (stop and remove the Docker container)" + read -p "Enter an option [1-2] (default: 1): " method_choice method_choice=${method_choice:-1} case "$method_choice" in 2) INSTALL_METHOD="docker" ;; @@ -75,39 +74,39 @@ else fi if [[ "$INSTALL_METHOD" == "docker" ]]; then - echo "正在卸载 OpenFlare Agent (Docker)..." + echo "Uninstalling OpenFlare Agent (Docker)..." # Check if container exists if command -v docker >/dev/null 2>&1; then if docker ps -a --format '{{.Names}}' | grep -Eq "^openflare-agent$"; then - echo "停止并移除 openflare-agent 容器..." + echo "Stopping and removing the openflare-agent container..." docker stop openflare-agent || true docker rm -f openflare-agent || true - echo "已成功移除 openflare-agent 容器。" + echo "The openflare-agent container was removed successfully." else - echo "未检测到 openflare-agent 容器。" + echo "No openflare-agent container was found." fi # In interactive mode, ask if user wants to remove docker image if [[ "$HAS_ARGS" == "false" ]]; then - read -p "是否删除 OpenFlare Agent 镜像 (ghcr.io/rain-kl/openflare-agent:latest)? (y/n) [n]: " remove_image_choice + read -p "Remove the OpenFlare Agent image (ghcr.io/rain-kl/openflare-agent:latest)? (y/n) [n]: " remove_image_choice remove_image_choice=${remove_image_choice:-n} case "$remove_image_choice" in [yY]) - echo "正在删除 Docker 镜像..." + echo "Removing the Docker image..." docker rmi ghcr.io/rain-kl/openflare-agent:latest || true ;; *) - echo "保留 Docker 镜像。" + echo "Keeping the Docker image." ;; esac fi else - echo "Error: 未检测到 Docker 运行环境,无法进行 Docker 方式的卸载。" + echo "Error: Docker was not found; cannot uninstall a Docker deployment." exit 1 fi - echo "OpenFlare Agent (Docker) 卸载完成。" + echo "OpenFlare Agent (Docker) uninstalled." exit 0 fi