diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index ce02e18..fbf8383 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # BlueArchiveToolkit 当前工作区状态 -- **更新时间**:2026-08-29 +- **更新时间**:2026-08-31 - **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态分支**:`experiment` - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 @@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `/.cas`、SQLite 索引为 `/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 和 `BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。 -15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 +15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。issue 46 已提供 `bat-api` 内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`; `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 @@ -217,12 +217,12 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker | 角色 | 所有者 | 状态 | |---|---|---| | 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 | -| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness | +| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard | | daemon RPC client | `internal/backendrpc` | 完成 | | 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 | | FFI | `internal/ffi` | 可选 | | 空目录 `api/` `pkg/` 等 | 占位 | 无实现 | -| Web | `web/` | 空(G-010) | +| Web | `web/` | 内嵌 dashboard MVP(issue #46);完整协作后台仍未完成 | 默认 Go/docs 门禁:`make test-go-api`、`make build-go-api`、`make check-docs`(无 FFI)。 @@ -256,7 +256,7 @@ make check-docs - 真实官方全量 smoke 长期运行报告(G-018 命令已固化)。 - `bat-api` 同机 live 联调:已由 `make bat-api-local-live-smoke` 在 `/tmp` 隔离目录完成;真实官方网络全量下载仍由 `make official-smoke` 独立跟踪。 -- Web(G-010)。 +- 完整 Web 协作后台(G-010 剩余部分)。 --- @@ -271,7 +271,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --watch ``` -资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest` 和 `official-sync-snapshot.json` contract fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run` 和 `translation.proofread` 可经 Web 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 +资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest` 和 `official-sync-snapshot.json` contract fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`localized.publish` 和 `localized.rollback` 可经 dashboard 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 生产要求: @@ -292,8 +292,8 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实 下一阶段必须优先完成: 1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/restart/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`schedule.*`(list/add/update/remove/run)、`parse.*`(status/text_units/errors)、`translation.*`(tasks/handoff/task.update/proofread/worker.run)、`localized.*`(status/publish/rollback)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:通用 manifest 驱动发布、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 -2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、translation admin 查询/控制、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 与同机 live smoke 已落地。失效 `resource_root` 或 manifest 不完整时会清空旧索引并使 `/readyz` 返回 `503`;编码 dot-segment 会在路由规范化前拒绝。refresh mtime/size 增量缓存和可选持久化另议。 -3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api dashboard 查询入口,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,`translation.worker.run` 已提供 mock/Crowdin provider worker、lease、重试和 TextUnit 译文结果落库,`translation.proofread` 已提供人工校对状态标记入口。受支持 TextAsset、TypeTree string field 和 managed-reference string field 已可从 workbench/worker 结果生成 localized patch,在独立 staging 校验后发布并显式 rollback,相关 status/publish/rollback RPC 与 bat-api 控制入口已暴露。剩余为翻译记忆、通用 manifest 发布和复杂重打包。 +2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation admin 查询控制、内嵌 dashboard、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 与同机 live smoke 已落地。失效 `resource_root` 或 manifest 不完整时会清空旧索引并使 `/readyz` 返回 `503`;编码 dot-segment 会在路由规范化前拒绝。refresh mtime/size 增量缓存和可选持久化另议。 +3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api dashboard 查询入口,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,且支持人工校对从当前 TextUnit 索引提交 `translation_results`;`translation.worker.run` 已提供 mock/Crowdin provider worker、lease、重试和 TextUnit 译文结果落库,`translation.proofread` 已提供人工校对状态标记入口。受支持 TextAsset、TypeTree string field 和 managed-reference string field 已可从 workbench/worker 结果生成 localized patch,在独立 staging 校验后发布并显式 rollback,相关 status/publish/rollback RPC 与 bat-api 控制入口已暴露。剩余为翻译记忆、通用 manifest 发布和复杂重打包。 4. Issue #3(已完成当前目标):AssetBundle UnityFS 基础解析校验已覆盖 header、block、directory、LZ4/LZMA、alignment、总大小、计数、路径安全、重复 directory、边界和隔离 UnityPy 真实样本;复杂对象解析与发布级重打包继续跟踪 G-005。 5. Issue #2(已完成当前目标):Addressables JSON/compact catalog 已提取 bundle provider、name、hash/size/CRC、资源类型和依赖关系,并贯通 ResourceEntry、SQLite 与 resource.index 输出;独立二进制格式仍明确拒绝。 6. 通用 Binary/JSON/Text Patch 基础已落地;受支持 localized patch 发布/rollback 已具备回归测试,复杂 AssetBundle 重打包、通用 manifest 发布和真实翻译记忆仍后置。 @@ -314,5 +314,5 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实 --- - **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。 -- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;完整 AssetBundle 重打包与通用 manifest 发布未完成。 +- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;完整 AssetBundle 重打包、完整 Web 协作后台与通用 manifest 发布未完成。 - **下一工程里程碑**:翻译记忆、通用 manifest Patch 构建、复杂 AssetBundle 解析和重打包;`bat-api` 同机 live 联调已完成。 diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index d4d9cbe..40994df 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -1,6 +1,6 @@ # BlueArchiveToolkit 文档索引 -- **更新时间**:2026-08-29 +- **更新时间**:2026-08-31 - **说明**:本索引用于快速定位当前权威文档和历史资料。 --- @@ -15,7 +15,7 @@ - `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。 - `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。 - `docs/guides/bat-api-local-live-smoke.md`:同机 Rust `bat` / Go `bat-api` live socket 联调 runbook。 -- `docs/guides/bat-workflows.md`:Rust `bat` 的 `res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` 调度接口。 +- `docs/guides/bat-workflows.md`:Rust `bat` 的 `res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` dashboard 接口。 - `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。 - `docs/architecture/resource-release-layout.md`:release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。 - `docs/architecture/assetbundle.md`:AssetBundle、Addressables、Serialized File、文本提取和 Patch 前置解析路线图。 @@ -31,7 +31,7 @@ - `docs/architecture/README.md`:总体架构设计。 - `docs/api/README.md`:API 设计入口。 -- `api/openapi/bat-api.yaml`:当前 `bat-api` 资源 bootstrap/分发 HTTP OpenAPI 静态规范。 +- `api/openapi/bat-api.yaml`:当前 `bat-api` 资源 bootstrap/分发、内嵌 dashboard 和管理控制面 HTTP OpenAPI 静态规范。 - `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。 - `docs/guides/development.md`:开发指南。 - `docs/guides/deployment.md`:部署指南。 @@ -112,7 +112,7 @@ - 官方原版资源与汉化产物目录分离:`./bat-resources` 只承载原版 release,`./bat-localized` 承载后续汉化 release;当前官方同步报告 `not_localized`,Patch 发布完成后才进入 `localized`。 - 官方 release 会维护 `official-parse-cache.json`,用于跳过未变化资源的重复解析。 - `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。 -- `cmd/bat-api` 资源分发 HTTP MVP(进度见 `docs/reports/GO_STATUS.md`)。 +- `cmd/bat-api` 资源分发 HTTP MVP 与内嵌 dashboard(进度见 `docs/reports/GO_STATUS.md`)。 - 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。 - `bat-api` 同机 live smoke 已固化为 `scripts/bat-api-local-live-smoke.sh` 和 `make bat-api-local-live-smoke`,覆盖真实 `bat.sock`、release 切换、未 ready、恢复和 CDN 读路径。 - `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index e0627dd..72c43e2 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -36,19 +36,19 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。 10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。 -11. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本查看/修改/清空、翻译任务状态回写、人工校对状态标记、既有 patch 能力的批量重打包和独立汉化 release 发布;issue 44 已接入 `translation.worker.run` provider worker,默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果;schedule CRUD、`translation.tasks` / `translation.handoff`、`translation.task.update`、`translation.worker.run` 和 `translation.proofread` 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 +11. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本查看/修改/清空、翻译任务状态回写、人工校对状态标记、既有 patch 能力的批量重打包和独立汉化 release 发布;issue 44 已接入 `translation.worker.run` provider worker,默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果;issue 46 已提供 `bat-api` 内嵌 dashboard MVP,直接调用已有鉴权接口控制资源、调度、任务、日志、parse、翻译和 localized 发布/回滚;schedule CRUD、`translation.tasks` / `translation.handoff`、`translation.task.update`、`translation.worker.run` 和 `translation.proofread` 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 ### 仍是骨架或占位 1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有受支持 localized patch 发布能力;MonoBehaviour/ScriptableObject 复杂字段级解析、整体重打包和通用 Patch 仍未完成。 2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环,通用 manifest 发布与整体 AssetBundle 重打包仍后置。 -3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发 = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。 +3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / 完整 Web 协作后台 / SDK 仍未完成。 4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。 5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract,`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract,`translation.proofread` 已提供汉化 workflow 人工校对标记 contract。 6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。 7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。 -8. 完整 Web、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发 OpenAPI 已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。 +8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。 9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。 ### 已验证 @@ -340,12 +340,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 ### Milestone 10:Web 管理后台 -**目标**:为翻译协作和资源管理提供可用后台。 +**目标**:在已落地的 `bat-api` 内嵌 dashboard MVP 之上,为翻译协作和资源管理提供完整后台。 交付物: 1. 登录、权限、用户角色。 -2. Dashboard:同步状态、翻译进度、质量问题、队列状态。 +2. Dashboard:同步状态、翻译进度、质量问题、队列状态;当前 MVP 已覆盖资源、调度、任务、日志、parse、翻译和 localized 控制。 3. 翻译审核:列表、详情、Diff、批量操作。 4. 术语管理:搜索、冲突提示、审核。 5. 资源浏览:版本、资源、Bundle、文本定位。 @@ -384,7 +384,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 ## 5. 推荐执行顺序 -近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是资源解析、增量变更集进入文本提取/翻译队列、真实官方资源长期运行验证,以及 provider worker。 +近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是翻译记忆、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。 建议顺序: @@ -392,7 +392,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 2. 完成 Milestone 5,再开始翻译系统。 3. 完成 Milestone 6 和 7,建立可审计翻译流程。 4. 完成 Milestone 8,形成可交付补丁。 -5. 最后补齐 CLI/API/Web/发布工程。 +5. 最后补齐完整 CLI/API/Web 协作后台和发布工程。 --- @@ -459,7 +459,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。 -已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发和同机 live 联调。下一阶段的关键是翻译记忆、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。 +已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是翻译记忆、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。 --- diff --git a/README.md b/README.md index b8a8514..c9f69ab 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。 -当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写和 CDN path 只读分发);`internal/backendrpc` 为 RPC client;`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、Web、AssetBundle 引擎、翻译和 Patch 仍在后续阶段。 +当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client;`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台、复杂 AssetBundle 重打包、翻译记忆和通用 Patch 发布仍在后续阶段。 --- @@ -15,7 +15,7 @@ - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 - `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`,report 按 plan 顺序、进度按完成数单调上报)、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json`、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*`、`resource.*`、`parse.*`、`translation.tasks/handoff/task.update`、`localized.status`、`catalog.*`、`task.*`、`patch.apply`、`unityfs.patch_*`)。 - `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。 -- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单和 translation admin 查询/控制入口;`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。 +- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。 - Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(G-008 已关闭:同步 CLI = Rust `bat`)。 - 官方同步会维护 `/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、archive entry、parse status 和 TextUnit format 分页查询索引。 @@ -27,11 +27,11 @@ 仍未完成: -- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面和 launcher 资源引导兼容已可用。 +- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。 - 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。 - 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。 - Translation Memory、Glossary、AI Provider。 -- SDK、Web 管理后台。 +- SDK、完整 Web 协作后台。 详细状态见: @@ -143,11 +143,11 @@ make official-smoke ## 技术栈 - Rust:CAS、官方资源同步核心、AssetBundle/Patch 引擎;当前生产同步入口是 `bat` binary。 -- Go:当前正式入口是 `bat-api` 资源 bootstrap/分发服务和 `internal/backendrpc`;完整游戏业务 API、SDK、Provider 编排仍按路线图推进,`cmd/bat` 仅为试验 CLI。 +- Go:当前正式入口是 `bat-api` 资源 bootstrap/分发服务、内嵌 dashboard 和 `internal/backendrpc`;完整游戏业务 API、SDK、Provider 编排仍按路线图推进,`cmd/bat` 仅为试验 CLI。 - `bat-ffi`:可选兼容层,只暴露无状态粗粒度 JSON C ABI,不承载 daemon、下载器、CAS handle 或主控制面。 - PostgreSQL:计划中的服务端主数据库。 - Redis:计划中的缓存、队列状态、限流和短期锁。 -- Vue 3 + TypeScript:计划中的 Web 管理后台。 +- Vue 3 + TypeScript:计划中的完整 Web 协作后台;当前已先提供无构建内嵌 dashboard。 - Docker / Docker Compose:数据库和后续服务部署配置。 --- @@ -169,7 +169,7 @@ BlueArchiveToolkit/ ├── cmd/ # Go CLI 试验骨架与后续产品入口 ├── pkg/ # Go SDK 包,尚未实现 ├── api/ # 预留 API 定义;bat-api OpenAPI 静态规范已提供,完整业务 API 尚未实现 -├── web/ # Web 管理后台,尚未实现 +├── web/ # bat-api 内嵌 dashboard 静态资产;完整协作后台仍在后续阶段 ├── deployments/ # Docker 和部署配置 ├── docs/ # 文档、历史报告和分析资料 ├── Cargo.toml @@ -183,13 +183,13 @@ BlueArchiveToolkit/ 近期优先级: -1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口;`cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整产品 CLI。 +1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard;`cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整产品 CLI。 2. 补齐 AssetBundle UnityFS 引擎级解析。 3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。 4. 基于 `translation.worker.run` provider worker 推进翻译记忆、完整 Patch 构建和发布/回滚闭环。 5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。 -不建议在 Go 产品入口、资源解析和文本提取基础能力完成前优先开发 Web UI。 +当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在翻译记忆、权限模型和持久化 API 明确后推进。 --- diff --git a/USERGUIDE.md b/USERGUIDE.md index 5e09db8..147c7de 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -137,7 +137,16 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \ | `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 | | `GET /openapi.yaml` | bat-api OpenAPI 文档 | | `GET /admin/` | 管理控制入口与允许操作列表 | +| `GET /admin/dashboard/` | 内嵌管理 dashboard 静态页面;页面调用的管理 API 仍需要 token | +| `GET /admin/diagnostics` | 读取 Rust daemon 诊断;需要管理 token | +| `GET /admin/logs?tail=200` | 读取 Rust daemon 日志尾部;需要管理 token | +| `GET /admin/tasks` | 读取 Rust daemon 任务列表;需要管理 token | +| `GET /admin/tasks/status?task_id=...` | 读取单项任务状态;需要管理 token | +| `GET /admin/tasks/logs?task_id=...` | 读取单项任务日志;需要管理 token | | `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token | +| `GET /admin/parse/status` | 读取当前 release 解析状态;需要管理 token | +| `GET /admin/parse/text-units?...` | 分页查询当前 release TextUnit 明细;需要管理 token | +| `GET /admin/parse/errors?...` | 分页查询当前 release 解析错误;需要管理 token | | `GET /admin/translation/tasks?limit=100&worker_status=failed` | 读取/过滤 Rust 翻译任务和 provider worker 状态;需要管理 token | | `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token | | `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token | @@ -147,7 +156,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn 生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志: -- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer `、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*`、`/admin/schedules` 和 `/admin/translation/*` 需要此 token。 +- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer `、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*`、`/admin/schedules`、`/admin/tasks*`、`/admin/logs`、`/admin/diagnostics`、`/admin/parse/*` 和 `/admin/translation/*` 需要此 token;`/admin/dashboard/` 静态资产默认免鉴权,便于浏览器打开后再在页面内配置 token。 - `BAT_API_AUTH_QUERY_PARAM`:query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。 - `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。 - `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。 @@ -170,7 +179,8 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `schedule-update` | `schedule.update` | 调度 mutation JSON | `202` + Rust schedule report | | `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report | | `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 | -| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "...", "failure_reason": "...", "provider_run_id": "..." }` | `202` + 当前任务记录 | +| `task-cancel` | `task.cancel` | `{ "task_id": "..." }` | `202` + 取消请求结果 | +| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "..." }] }` | `202` + 当前任务记录 | | `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task | | `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 | | `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }` 或 `{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest | diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index 0231530..678bb17 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -102,12 +102,210 @@ paths: responses: "200": description: OpenAPI YAML. + /admin/dashboard/: + get: + summary: Embedded bat-api dashboard + security: [] + responses: + "200": + description: Static dashboard HTML. /admin/: get: summary: Admin control entry responses: "200": description: Admin links and allowlisted control actions. + /admin/diagnostics: + get: + summary: Read Rust daemon doctor diagnostics + responses: + "200": + description: Current daemon.doctor report. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat diagnostics backend is unavailable. + /admin/logs: + get: + summary: Read Rust daemon log tail + parameters: + - name: tail + in: query + schema: + type: integer + minimum: 1 + maximum: 2000 + responses: + "200": + description: Current daemon.logs report. + "400": + description: Invalid log query. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat log backend is unavailable. + /admin/tasks: + get: + summary: List Rust-owned async daemon tasks + responses: + "200": + description: Current task.list report. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat task backend is unavailable. + /admin/tasks/status: + get: + summary: Read one Rust-owned async daemon task + parameters: + - name: task_id + in: query + required: true + schema: + type: string + responses: + "200": + description: Current task.status report. + "400": + description: Missing or invalid task_id. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat task backend is unavailable. + /admin/tasks/logs: + get: + summary: Read one Rust-owned async daemon task log + parameters: + - name: task_id + in: query + required: true + schema: + type: string + responses: + "200": + description: Current task.logs report. + "400": + description: Missing or invalid task_id. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat task backend is unavailable. + /admin/parse/status: + get: + summary: Read Rust-owned parse/TextUnit index status + responses: + "200": + description: Current parse.status report. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat parse backend is unavailable. + /admin/parse/text-units: + get: + summary: Query Rust-owned TextUnit index entries + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + - name: destination + in: query + schema: + type: string + - name: path_pattern + in: query + schema: + type: string + - name: archive_entry + in: query + schema: + type: string + - name: path_id + in: query + schema: + type: integer + format: int64 + - name: class_id + in: query + schema: + type: integer + - name: field_path + in: query + schema: + type: string + - name: format + in: query + schema: + type: string + responses: + "200": + description: Current parse.text_units report. + "400": + description: Invalid TextUnit query. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat parse backend is unavailable. + /admin/parse/errors: + get: + summary: Query Rust-owned TextUnit extraction diagnostics + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + - name: destination + in: query + schema: + type: string + - name: path_pattern + in: query + schema: + type: string + - name: archive_entry + in: query + schema: + type: string + - name: path_id + in: query + schema: + type: integer + format: int64 + - name: class_id + in: query + schema: + type: integer + - name: field_path + in: query + schema: + type: string + - name: format + in: query + schema: + type: string + responses: + "200": + description: Current parse.errors report. + "400": + description: Invalid parse error query. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat parse backend is unavailable. /admin/schedules: get: summary: List Rust-owned resource workflow schedules @@ -225,7 +423,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] requestBody: required: false content: @@ -278,7 +476,19 @@ paths: type: string provider: type: string - enum: [mock, crowdin] + translation_results: + type: array + items: + type: object + required: [unit_id, source_text, translated_text] + additionalProperties: false + properties: + unit_id: + type: string + source_text: + type: string + translated_text: + type: string fixture_path: type: string concurrency: diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index f434f63..a5a9e4b 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -231,15 +231,26 @@ bat parse schedule list --state-dir /tmp/bat-schedule `schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N` 限制本轮最多执行 N 个到期计划。 -## bat-api 调度接口 +## bat-api 调度与 dashboard 接口 -dashboard 通过 `bat-api` 转发到 Rust `bat.sock`,不维护第二份计划状态。Rust RPC 方法为: +内嵌 dashboard 由 `bat-api` 直接服务于 `GET /admin/dashboard/`。页面静态资产免 +token 读取,但资源、调度、任务、日志、解析和翻译控制都通过 `bat-api` 转发到 +Rust `bat.sock`,不维护第二份计划状态或翻译状态。Rust RPC 方法为: - `schedule.list` - `schedule.add` - `schedule.update` - `schedule.remove` - `schedule.run` +- `task.list` +- `task.status` +- `task.logs` +- `task.cancel` +- `daemon.logs` +- `daemon.doctor` +- `parse.status` +- `parse.text_units` +- `parse.errors` `bat-api` 对应接口为 `GET /admin/schedules` 和 `POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`, @@ -250,6 +261,18 @@ contract:`id`、`group`、`action`、`args`、`next_run_unix_seconds`、 `enabled`;`schedule.list` 额外接受 `id`、`group`、`enabled` 过滤, `schedule.run` 额外接受 `group`、`force` 和 `max_runs`。 +任务和诊断接口同样要求管理 token:`GET /admin/diagnostics` 转发 +`daemon.doctor`,`GET /admin/logs?tail=200` 转发 `daemon.logs`, +`GET /admin/tasks`、`GET /admin/tasks/status?task_id=...` 和 +`GET /admin/tasks/logs?task_id=...` 转发 `task.*` 查询。取消任务使用 +`POST /admin/control/task-cancel`,请求字段为 `task_id`。 + +解析查询接口为 `GET /admin/parse/status`、 +`GET /admin/parse/text-units` 和 `GET /admin/parse/errors`,均只读转发当前 +Rust release 的 `parse.*` 数据。`text-units` 与 `errors` 支持 `offset`、 +`limit`、`destination`、`path_pattern`、`archive_entry`、`path_id`、`class_id`、 +`field_path` 和 `format` query,`limit` 范围为 `1..=1000`。 + 翻译任务状态可由已鉴权的 dashboard 通过 `GET /admin/translation/tasks` 查询,query 过滤项包括 `offset`、`limit`、`task_id`、`release_id`、 `destination`、`path_pattern`、`archive_entry`、`status`、`worker_status`、 @@ -259,8 +282,12 @@ Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。 翻译任务状态也可由已鉴权的 dashboard 通过 `POST /admin/control/translation-task-update` 回写,请求字段为 -`task_id`、`status`,以及可选的 `failure_reason`、`provider_run_id`; -该接口只转发 `translation.task.update`。 +`task_id`、`status`,以及可选的 `failure_reason`、`provider`、 +`provider_run_id` 和 `translation_results`;该接口只转发 +`translation.task.update`。人工校对流程提交译文时必须使用 `status=completed`, +并为每个 `translation_results[]` 提供 `unit_id`、`source_text` 和 +`translated_text`,Rust 会用当前 `official-textunit-index.json` 校验 unit、 +source text、destination 和 archive entry 后再落库。 `POST /admin/control/translation-worker-run` 会触发 Rust 侧 `translation.worker.run`,请求字段为 `provider`、`fixture_path`、 diff --git a/docs/guides/development.md b/docs/guides/development.md index 9a78b54..6266dd4 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -155,7 +155,7 @@ make check-docs Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准: - **同步/运维命令行** = Rust `bat`(近乎全自动) -- **资源 bootstrap/分发服务** = `cmd/bat-api`(`make build-go-api`) +- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api`(`make build-go-api`) - **默认 Go 门禁** = `make test-go-api`(无 FFI) - 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名 - 修改 FFI 时再跑 `make test-go-ffi` @@ -172,7 +172,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \ --refresh-interval 0 ``` -其中 `make bat-api-local-live-smoke` 会在 `/tmp` 中启动真实 Rust daemon 和 Go API,覆盖 release 切换、清单不完整、无 release、RPC 断开、server-info、CDN Range/缓存和路径越界;报告保留在该次 smoke 的临时目录。单独的 `--resource-root` 命令只验证 fixture HTTP 形态,不替代 live socket 联调。 +其中 `make bat-api-local-live-smoke` 会在 `/tmp` 中启动真实 Rust daemon 和 Go API,覆盖 release 切换、清单不完整、无 release、RPC 断开、server-info、CDN Range/缓存和路径越界;报告保留在该次 smoke 的临时目录。单独的 `--resource-root` 命令只验证 fixture HTTP 形态,不替代 live socket 联调。浏览器检查内嵌 dashboard 时打开 `http://127.0.0.1:18080/admin/dashboard/`,再在页面内填入管理 token。 生产默认路径仍是 `--socket` / `BAT_API_SOCKET`,资源根由 Rust `bat` RPC 返回;`--resource-root` 只用于上述 fixture 或应急只读诊断。 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index c7b3d48..5856825 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -256,8 +256,12 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化 `translation.task.update` 只更新当前 release 的 SQLite 状态库,不改写 immutable 队列文件,也不主动访问 Crowdin。`status` 支持 `queued`、`running`、`failed`、 `completed` 和 `skipped`;进入 `running` 会增加 attempt count,`completed` 会 -记录完成时间,`failed` 可写入 `failure_reason`。因此 worker 消费 handoff 后, -bat-api 可通过 `translation.tasks` 查询单项任务,也可通过 +记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在 +`status=completed` 时额外提交 `provider`、`provider_run_id` 和 +`translation_results[]`,每个结果必须包含 `unit_id`、`source_text` 和 +`translated_text`;Rust 会用当前 `official-textunit-index.json` 校验 unit、 +source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程 +消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过 `translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff` 不会触发下载或 provider 网络请求;没有当前 release 或任务队列时返回 `data.available=false`。 @@ -468,6 +472,9 @@ CLI 对应关系: | `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发 | | `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 | | `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 | +| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 | +| `TaskBackend` | `task.list`、`task.status`、`task.logs`、`task.cancel` | 鉴权后的 daemon 任务查询和取消 | +| `ParseBackend` | `parse.status`、`parse.text_units`、`parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 | | `TranslationBackend` | `translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 | | `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index f687bcc..077d39f 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -259,19 +259,24 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ 排期:P2 主体可联调;持久化 API 层与完整 launcher/业务链另议。 -### G-010:Web 管理后台尚未实现 +### G-010:完整 Web 管理后台仍未实现 现象: -- `web/` 只有目录结构。 +- `web/` 已提供无构建内嵌 dashboard MVP,作为 `bat-api` 静态资产服务于 + `/admin/dashboard/`。 +- 当前页面可调用已鉴权的资源、调度、任务、日志、parse、翻译和 localized 发布/回滚 + 接口,但不提供独立登录、角色权限、术语管理、批量审核工作流或构建型前端工程。 影响: -- 翻译审核、术语管理、Dashboard 无 UI。 +- 基础资源/调度/翻译控制可以在 dashboard 上完成。 +- 协作式翻译审核、术语管理和权限隔离仍缺少完整 UI。 验收: - 登录、权限、翻译审核、术语管理基础流程可用。 +- Dashboard E2E、静态资产构建/发布策略和错误态交互纳入常规门禁。 --- diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index 9a5d507..f1da426 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -1,6 +1,6 @@ # Go 侧进度与边界(权威) -- **更新时间**:2026-08-29 +- **更新时间**:2026-08-31 - **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。 - **关联**:issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7、`docs/guides/bat-api-local-live-smoke.md` @@ -11,7 +11,7 @@ | 名称 | 路径 / 产物 | 角色 | 是否产品入口 | |---|---|---|---| | **Rust `bat`** | `infrastructure` bin → 正式同步二进制 | 官方资源**自动**发现 / 拉取 / 校验 / 发布 / watch·daemon / 运维子命令 | **是(同步与运维命令行)** | -| **Go `bat-api`** | `cmd/bat-api` → `bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API | **是(bootstrap/分发服务)** | +| **Go `bat-api`** | `cmd/bat-api` → `bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API + 内嵌 dashboard | **是(bootstrap/分发服务与管理入口)** | | **Go 试验 CLI** | `cmd/bat` → `bin/bat-go`(不得再叫 `bin/bat`) | FFI 演示骨架 | **否** | ### 1.1 「同步命令行 = Rust `bat`」的含义 @@ -20,7 +20,7 @@ 这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口: -- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写和已发布资源字节),不是再做一套同步 CLI。 +- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写、已发布资源字节和内嵌管理 dashboard),不是再做一套同步 CLI。 - Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。 ### 1.2 `bat` 与 `bat-api` 的关系 @@ -31,8 +31,8 @@ |---|---|---| | 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata | | 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` | -| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info` 和 CDN path,组织给客户端/补丁器使用 | -| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引;认证 Web 控制面仅白名单转发 reload/refresh/restart/sync/verify/repair/catalog-refresh,不持有或写入同步状态 | +| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info`、CDN path 和 `/admin/dashboard/`,组织给客户端/补丁器/维护者使用 | +| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引;认证 Web 控制面仅白名单转发 reload/refresh/restart/sync/verify/repair/catalog-refresh、schedule/task/log/parse/translation/localized 方法,不持有或写入同步状态 | 这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。 @@ -40,7 +40,7 @@ 1. **G-008 决策关闭(wontfix)**:不另做产品级 Go 同步/运维 CLI。 2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。 -3. **USERGUIDE 的 bat-api 基础章节已补**;同机 live 联调 runbook 已补,真实官方网络下载仍由独立 smoke 负责。 +3. **USERGUIDE 的 bat-api 基础章节已补**;同机 live 联调 runbook 和内嵌 dashboard MVP 已补,真实官方网络下载仍由独立 smoke 负责。 --- @@ -96,11 +96,11 @@ |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | | RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror | -| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` | +| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + 内嵌 dashboard + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README | -| Web | `web/` | **空** | G-010 | +| Web | `web/` | **内嵌 dashboard MVP** | issue #46;完整协作后台、登录/角色和术语管理仍属 G-010 剩余 | `go list ./...` 当前包: @@ -134,9 +134,9 @@ make build-go-cli # 产出 bin/bat-go | 项 | 状态 | |---|---| | G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) | -| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*` dashboard 代理、翻译状态回写代理、同机 live smoke 和部署模板;持久化仍另议 | +| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*`、`task.*`、`parse.*`、翻译状态回写代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 | | issue #19 | **验收完成,本提交关闭**;`make bat-api-local-live-smoke` 已在同机隔离环境覆盖 live RPC、release 切换、清单不完整、未 ready、server-info 和 CDN path | -| G-010 Web | 未开始 | +| G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 | --- diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 4913501..3c6a7ef 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -4190,6 +4190,7 @@ fn dispatch_translation_tasks_filters_current_queue() { let output_root = temp.path().join("output"); let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); write_textunit_task_queue_fixture(¤t_dir); + write_textunit_index_fixture(¤t_dir); write_translation_task_repository_fixture(¤t_dir); let envelope = dispatch_rpc_method( @@ -4290,7 +4291,13 @@ fn dispatch_translation_tasks_filters_current_queue() { Some(serde_json::json!({ "task_id": "textunit/v-current/Bundle/a.bundle", "status": "completed", - "provider_run_id": "crowdin-run-2" + "provider": "manual", + "provider_run_id": "manual-run-1", + "translation_results": [{ + "unit_id": "direct:a#unit:0", + "source_text": "こんにちは", + "translated_text": "你好" + }] })), ), &state_dir, @@ -4305,7 +4312,16 @@ fn dispatch_translation_tasks_filters_current_queue() { value["data"]["entry"]["failure_reason"], serde_json::Value::Null ); - assert_eq!(value["data"]["entry"]["provider_run_id"], "crowdin-run-2"); + assert_eq!(value["data"]["entry"]["provider_run_id"], "manual-run-1"); + assert_eq!(value["data"]["entry"]["provider"], "manual"); + assert_eq!( + value["data"]["entry"]["translation_results"][0]["unit_id"], + "direct:a#unit:0" + ); + assert_eq!( + value["data"]["entry"]["translation_results"][0]["translated_text"], + "你好" + ); assert!(value["data"]["entry"]["completed_unix_seconds"].is_number()); let envelope = dispatch_rpc_method( @@ -4329,7 +4345,7 @@ fn dispatch_translation_tasks_filters_current_queue() { assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated"); assert_eq!( value["data"]["handoff"]["provider_runs"][0]["provider_run_id"], - "crowdin-run-2" + "manual-run-1" ); } diff --git a/infrastructure/src/bin/bat/translation_query.rs b/infrastructure/src/bin/bat/translation_query.rs index fa296f3..ecdfafb 100644 --- a/infrastructure/src/bin/bat/translation_query.rs +++ b/infrastructure/src/bin/bat/translation_query.rs @@ -1,5 +1,12 @@ use super::*; +#[derive(Debug, Deserialize)] +struct TranslationTaskResultUpdateParam { + unit_id: String, + source_text: String, + translated_text: String, +} + pub(super) fn build_translation_tasks_report( state_dir: &Path, query: OfficialTextUnitTaskQuery, @@ -170,6 +177,13 @@ pub(super) fn update_translation_task_status_report( .or_else(|| rpc_string_param(params, "reason")) .map(str::to_string); let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string); + let provider = rpc_string_param(params, "provider").map(str::to_string); + let result_params = translation_task_result_params(params)?; + if !result_params.is_empty() && status != TranslationTaskStatus::Completed { + return Err(anyhow::anyhow!( + "translation_results 只能随 completed 状态写入" + )); + } let (_, version_state) = read_daemon_resource_state(state_dir)?; let current = version_state .as_ref() @@ -182,6 +196,22 @@ pub(super) fn update_translation_task_status_report( repository_path.display() )); } + let textunit_index = if result_params.is_empty() { + None + } else { + Some( + read_textunit_index_at(¤t.resource_root) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| { + anyhow::anyhow!("当前 release 缺少 TextUnit 明细索引,无法校验人工校对结果") + })?, + ) + }; + let result_provider = provider.clone().unwrap_or_else(|| "manual".to_string()); + let result_timestamp = unix_seconds_now(); + let result_provider_run_id = provider_run_id + .clone() + .unwrap_or_else(|| format!("manual-{result_timestamp}")); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -190,10 +220,36 @@ pub(super) fn update_translation_task_status_report( let repository = SqliteTranslationTaskRepository::open(&repository_path) .await .map_err(|error| anyhow::anyhow!("{error}"))?; - repository - .update_status(task_id, status, failure_reason, provider_run_id) - .await - .map_err(|error| anyhow::anyhow!("{error}")) + if let Some(index) = textunit_index.as_ref() { + let current_task = repository + .find(task_id) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let results = build_manual_translation_results( + ¤t_task, + index, + &result_params, + &result_provider, + &result_provider_run_id, + result_timestamp, + )?; + repository + .update_status_with_results( + task_id, + status, + failure_reason, + Some(result_provider_run_id), + Some(result_provider), + Some(&results), + ) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } else { + repository + .update_status(task_id, status, failure_reason, provider_run_id) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } })?; Ok(serde_json::json!({ "available": true, @@ -215,6 +271,75 @@ pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json:: }) } +fn translation_task_result_params( + params: Option<&serde_json::Value>, +) -> anyhow::Result> { + let Some(value) = params + .and_then(|params| params.get("translation_results")) + .or_else(|| params.and_then(|params| params.get("results"))) + else { + return Ok(Vec::new()); + }; + if value.is_null() { + return Ok(Vec::new()); + } + serde_json::from_value(value.clone()) + .map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}")) +} + +fn build_manual_translation_results( + task: &bat_infrastructure::PersistedTranslationTask, + index: &bat_infrastructure::OfficialTextUnitIndex, + params: &[TranslationTaskResultUpdateParam], + provider: &str, + provider_run_id: &str, + translated_unix_seconds: u64, +) -> anyhow::Result> { + let index_by_id = index + .units + .iter() + .map(|unit| (unit.id.as_str(), unit)) + .collect::>(); + let mut seen = std::collections::BTreeSet::new(); + let mut results = Vec::with_capacity(params.len()); + for param in params { + let unit_id = param.unit_id.trim(); + if unit_id.is_empty() { + return Err(anyhow::anyhow!("translation_results[].unit_id 不能为空")); + } + if !seen.insert(unit_id.to_string()) { + return Err(anyhow::anyhow!( + "translation_results 包含重复 TextUnit:{unit_id}" + )); + } + let unit = index_by_id + .get(unit_id) + .ok_or_else(|| anyhow::anyhow!("translation_results 引用了未知 TextUnit:{unit_id}"))?; + if unit.destination != task.task.destination + || unit.archive_entry != task.task.archive_entry + { + return Err(anyhow::anyhow!( + "TextUnit {unit_id} 不属于翻译任务 {}", + task.task.task_id + )); + } + if param.source_text != unit.source_text { + return Err(anyhow::anyhow!( + "TextUnit {unit_id} 的 source_text 与当前索引不一致" + )); + } + results.push(bat_infrastructure::TranslationTaskUnitResult { + unit_id: unit_id.to_string(), + source_text: param.source_text.clone(), + translated_text: param.translated_text.clone(), + provider: provider.to_string(), + provider_run_id: provider_run_id.to_string(), + translated_unix_seconds, + }); + } + Ok(results) +} + pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value { serde_json::json!({ "task_id": query.task_id.clone(), diff --git a/infrastructure/src/translation_tasks.rs b/infrastructure/src/translation_tasks.rs index 99c91e6..444c42c 100644 --- a/infrastructure/src/translation_tasks.rs +++ b/infrastructure/src/translation_tasks.rs @@ -1217,6 +1217,27 @@ impl SqliteTranslationTaskRepository { status: TranslationTaskStatus, failure_reason: Option, provider_run_id: Option, + ) -> Result { + self.update_status_with_results( + task_id, + status, + failure_reason, + provider_run_id, + None, + None, + ) + .await + } + + /// Updates provider state and optionally replaces durable TextUnit results. + pub async fn update_status_with_results( + &self, + task_id: &str, + status: TranslationTaskStatus, + failure_reason: Option, + provider_run_id: Option, + provider: Option, + translation_results: Option<&[TranslationTaskUnitResult]>, ) -> Result { let current = self.find(task_id).await?; let now = unix_seconds_now_i64(); @@ -1228,6 +1249,13 @@ impl SqliteTranslationTaskRepository { current.attempt_count }; let normalized_reason = failure_reason.filter(|reason| !reason.trim().is_empty()); + let provider_run_id = provider_run_id.filter(|value| !value.trim().is_empty()); + let provider = provider.filter(|value| !value.trim().is_empty()); + let translation_results_json = + translation_results + .map(serde_json::to_string) + .transpose() + .map_err(|error| bat_core::Error::Serialization(error.to_string()))?; let completed = (status == TranslationTaskStatus::Completed).then_some(now); sqlx::query( r#" @@ -1235,6 +1263,8 @@ impl SqliteTranslationTaskRepository { SET worker_status = ?2, failure_reason = ?3, attempt_count = ?4, updated_unix_seconds = ?5, completed_unix_seconds = ?6, provider_run_id = COALESCE(?7, provider_run_id), + provider = COALESCE(?8, provider), + translation_results_json = COALESCE(?9, translation_results_json), lease_owner = NULL, lease_expires_unix_seconds = NULL, failure_class = NULL, failure_retryable = 0, next_attempt_unix_seconds = NULL @@ -1248,6 +1278,8 @@ impl SqliteTranslationTaskRepository { .bind(now) .bind(completed) .bind(provider_run_id) + .bind(provider) + .bind(translation_results_json) .execute(&self.pool) .await .map_err(db_error)?; @@ -1696,6 +1728,52 @@ mod tests { assert_eq!(retrievable[0].attempt_count, 1); } + #[tokio::test] + async fn sqlite_translation_tasks_persist_manual_results_without_worker_lease() { + let temp = tempfile::TempDir::new().unwrap(); + let repository = + SqliteTranslationTaskRepository::new(temp.path().join("translation-tasks.sqlite")) + .await + .unwrap(); + let queue = queue(vec![task( + "task-a", + "Bundles/a.bundle", + OfficialTextUnitTaskStatus::QueuedOffline, + Some(OfficialParseStatus::Parsed), + None, + )]); + repository.sync_queue(&queue).await.unwrap(); + let result = TranslationTaskUnitResult { + unit_id: "unit-a".to_string(), + source_text: "source".to_string(), + translated_text: "manual translation".to_string(), + provider: "manual".to_string(), + provider_run_id: "manual-run-1".to_string(), + translated_unix_seconds: 321, + }; + + let updated = repository + .update_status_with_results( + "task-a", + TranslationTaskStatus::Completed, + None, + Some("manual-run-1".to_string()), + Some("manual".to_string()), + Some(std::slice::from_ref(&result)), + ) + .await + .unwrap(); + + assert_eq!(updated.task_status, TranslationTaskStatus::Completed); + assert_eq!(updated.provider.as_deref(), Some("manual")); + assert_eq!(updated.provider_run_id.as_deref(), Some("manual-run-1")); + assert_eq!(updated.translation_results, vec![result.clone()]); + assert_eq!( + repository.find("task-a").await.unwrap().translation_results, + vec![result] + ); + } + #[tokio::test] async fn sqlite_translation_tasks_recover_expired_leases_for_retry() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/internal/api/admin.go b/internal/api/admin.go index d5470d6..e92c315 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -15,11 +15,17 @@ import ( const adminControlMaxBodyBytes = 1024 const adminScheduleMaxBodyBytes = 64 * 1024 +const adminDefaultLogTail = 200 +const adminMaxLogTail = 2000 type adminControlRequest struct { Force bool `json:"force"` } +type adminTaskRequest struct { + TaskID string `json:"task_id"` +} + func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") @@ -36,7 +42,16 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/v1/release", "/v1/resources", "/openapi.yaml", + "/admin/dashboard/", "/admin/schedules", + "/admin/tasks", + "/admin/tasks/status", + "/admin/tasks/logs", + "/admin/diagnostics", + "/admin/logs", + "/admin/parse/status", + "/admin/parse/text-units", + "/admin/parse/errors", "/admin/translation/tasks", "/admin/translation/handoff", "/admin/translation/status", @@ -53,6 +68,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/schedule-update", "/admin/control/schedule-remove", "/admin/control/schedule-run", + "/admin/control/task-cancel", "/admin/control/translation-task-update", "/admin/control/translation-worker-run", "/admin/control/translation-proofread", @@ -85,6 +101,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { s.handleAdminScheduleControl(w, r, action) return } + if action == "task-cancel" { + s.handleAdminTaskCancel(w, r) + return + } if action == "translation-task-update" { s.handleAdminTranslationTaskUpdate(w, r) return @@ -188,8 +208,8 @@ func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http if !decodeAdminTranslationJSON(w, r, ¶ms) { return } - if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" { - writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", "translation task update requires task_id and status") + if err := validateTranslationTaskUpdateParams(params); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", err.Error()) return } result, err := backend.TranslationTaskUpdate(r.Context(), params) @@ -332,6 +352,253 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque writeNoStoreJSON(w, http.StatusOK, result) } +func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + if s.backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "diagnostics_backend_unavailable", "Rust bat diagnostics backend is unavailable") + return + } + result, err := s.backend.DaemonDoctor(r.Context()) + if err != nil { + s.writeControlBackendError(w, "diagnostics", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(DaemonLogsBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "diagnostics_backend_unavailable", "Rust bat log backend is unavailable") + return + } + tail, err := adminLogTail(r) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_log_query", err.Error()) + return + } + result, err := backend.DaemonLogs(r.Context(), tail) + if err != nil { + s.writeControlBackendError(w, "daemon-logs", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminTasks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(TaskBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable") + return + } + result, err := backend.TaskList(r.Context()) + if err != nil { + s.writeControlBackendError(w, "task-list", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminTaskStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(TaskBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable") + return + } + taskID, err := adminTaskIDQuery(r) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_task_query", err.Error()) + return + } + result, err := backend.TaskStatus(r.Context(), taskID) + if err != nil { + s.writeControlBackendError(w, "task-status", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminTaskLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(TaskBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable") + return + } + taskID, err := adminTaskIDQuery(r) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_task_query", err.Error()) + return + } + result, err := backend.TaskLogs(r.Context(), taskID) + if err != nil { + s.writeControlBackendError(w, "task-logs", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminParseStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(ParseBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "parse_backend_unavailable", "Rust bat parse backend is unavailable") + return + } + result, err := backend.ParseStatus(r.Context()) + if err != nil { + s.writeControlBackendError(w, "parse-status", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminParseTextUnits(w http.ResponseWriter, r *http.Request) { + s.handleAdminParseTextUnitQuery(w, r, false) +} + +func (s *Server) handleAdminParseErrors(w http.ResponseWriter, r *http.Request) { + s.handleAdminParseTextUnitQuery(w, r, true) +} + +func (s *Server) handleAdminParseTextUnitQuery(w http.ResponseWriter, r *http.Request, errorsOnly bool) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(ParseBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "parse_backend_unavailable", "Rust bat parse backend is unavailable") + return + } + query, err := parseTextUnitQueryParams(r) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_parse_query", err.Error()) + return + } + var result json.RawMessage + if errorsOnly { + result, err = backend.ParseErrors(r.Context(), query) + } else { + result, err = backend.ParseTextUnits(r.Context(), query) + } + if err != nil { + action := "parse-text-units" + if errorsOnly { + action = "parse-errors" + } + s.writeControlBackendError(w, action, err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminTaskCancel(w http.ResponseWriter, r *http.Request) { + backend, ok := s.backend.(TaskBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable") + return + } + var params adminTaskRequest + if !decodeAdminTaskJSON(w, r, ¶ms) { + return + } + taskID := strings.TrimSpace(params.TaskID) + if taskID == "" { + writeErrorJSON(w, http.StatusBadRequest, "invalid_task_params", "task cancel requires task_id") + return + } + result, err := backend.TaskCancel(r.Context(), taskID) + if err != nil { + s.writeControlBackendError(w, "task-cancel", err) + return + } + writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ + Service: "bat-api", + Action: "task-cancel", + RPCMethod: "task.cancel", + Status: "accepted", + Result: result, + }) +} + func (s *Server) handleAdminTranslationTasks(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") @@ -480,6 +747,46 @@ func translationTaskListParams(r *http.Request) (backendrpc.TranslationTaskListP return params, nil } +func parseTextUnitQueryParams(r *http.Request) (backendrpc.TextUnitQueryParams, error) { + query := r.URL.Query() + params := backendrpc.TextUnitQueryParams{ + Destination: strings.TrimSpace(query.Get("destination")), + PathPattern: strings.TrimSpace(query.Get("path_pattern")), + ArchiveEntry: strings.TrimSpace(query.Get("archive_entry")), + FieldPath: strings.TrimSpace(query.Get("field_path")), + Format: strings.TrimSpace(query.Get("format")), + } + if raw := strings.TrimSpace(query.Get("offset")); raw != "" { + offset, err := strconv.ParseInt(raw, 10, 32) + if err != nil || offset < 0 { + return backendrpc.TextUnitQueryParams{}, errors.New("offset must be a non-negative integer") + } + params.Offset = int(offset) + } + if raw := strings.TrimSpace(query.Get("limit")); raw != "" { + limit, err := strconv.ParseInt(raw, 10, 32) + if err != nil || limit < 1 || limit > 1000 { + return backendrpc.TextUnitQueryParams{}, errors.New("limit must be in 1..=1000") + } + params.Limit = int(limit) + } + if raw := strings.TrimSpace(query.Get("path_id")); raw != "" { + pathID, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return backendrpc.TextUnitQueryParams{}, errors.New("path_id must be a signed integer") + } + params.PathID = &pathID + } + if raw := strings.TrimSpace(query.Get("class_id")); raw != "" { + classID, err := strconv.Atoi(raw) + if err != nil { + return backendrpc.TextUnitQueryParams{}, errors.New("class_id must be a signed integer") + } + params.ClassID = &classID + } + return params, nil +} + func firstTrimmedQuery(query url.Values, keys ...string) string { for _, key := range keys { values := query[key] @@ -493,6 +800,26 @@ func firstTrimmedQuery(query url.Values, keys ...string) string { return "" } +func adminLogTail(r *http.Request) (int, error) { + raw := strings.TrimSpace(r.URL.Query().Get("tail")) + if raw == "" { + return adminDefaultLogTail, nil + } + tail, err := strconv.Atoi(raw) + if err != nil || tail < 1 || tail > adminMaxLogTail { + return 0, errors.New("tail must be in 1..=2000") + } + return tail, nil +} + +func adminTaskIDQuery(r *http.Request) (string, error) { + taskID := strings.TrimSpace(r.URL.Query().Get("task_id")) + if taskID == "" { + return "", errors.New("task_id is required") + } + return taskID, nil +} + func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) { backend, ok := s.backend.(ScheduleBackend) if !ok || backend == nil { @@ -567,6 +894,34 @@ func decodeAdminTranslationJSON(w http.ResponseWriter, r *http.Request, target a return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation request") } +func decodeAdminTaskJSON(w http.ResponseWriter, r *http.Request, target any) bool { + return decodeAdminJSON(w, r, target, "invalid_task_params", "task request") +} + +func validateTranslationTaskUpdateParams(params backendrpc.TranslationTaskUpdateParams) error { + if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" { + return errors.New("translation task update requires task_id and status") + } + if len(params.TranslationResults) == 0 { + return nil + } + if strings.TrimSpace(params.Status) != "completed" { + return errors.New("translation_results can only be submitted with completed status") + } + seen := make(map[string]struct{}, len(params.TranslationResults)) + for _, result := range params.TranslationResults { + unitID := strings.TrimSpace(result.UnitID) + if unitID == "" { + return errors.New("translation_results unit_id is required") + } + if _, ok := seen[unitID]; ok { + return errors.New("translation_results unit_id must be unique") + } + seen[unitID] = struct{}{} + } + return nil +} + func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunParams) error { if params.Concurrency != nil && (*params.Concurrency < 1 || *params.Concurrency > 256) { return errors.New("translation worker concurrency must be in 1..=256") diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 2a06055..97550c7 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -560,6 +560,11 @@ type fakeBackend struct { catalog json.RawMessage resource *backendrpc.ResourceState manifest *backendrpc.ResourceManifestPage + daemonLogs *backendrpc.LogsReport + taskList *backendrpc.TaskList + taskStatus *backendrpc.TaskRecord + taskLogs *backendrpc.TaskLogs + taskCancel *backendrpc.TaskCancelResult } func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) { @@ -585,10 +590,49 @@ func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { return f.manifest, nil } +func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) { + if f.daemonLogs != nil { + return f.daemonLogs, nil + } + return &backendrpc.LogsReport{Command: "logs", Status: "ok", Message: "ok", LogPath: "/tmp/bat-daemon.log", Exists: true, Empty: false, Bytes: 1, TotalLines: 1, ReturnedLines: 1, Content: "line-1\n"}, nil +} +func (f *fakeBackend) TaskList(ctx context.Context) (*backendrpc.TaskList, error) { + if f.taskList != nil { + return f.taskList, nil + } + return &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{}}, nil +} +func (f *fakeBackend) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) { + if f.taskStatus != nil { + out := *f.taskStatus + out.ID = taskID + return &out, nil + } + return &backendrpc.TaskRecord{ID: taskID, Kind: "resource.sync", Status: "running"}, nil +} +func (f *fakeBackend) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) { + if f.taskLogs != nil { + out := *f.taskLogs + out.TaskID = taskID + return &out, nil + } + return &backendrpc.TaskLogs{TaskID: taskID, Lines: []string{"task-log-line-1"}}, nil +} +func (f *fakeBackend) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) { + if f.taskCancel != nil { + out := *f.taskCancel + out.TaskID = taskID + return &out, nil + } + return &backendrpc.TaskCancelResult{TaskID: taskID, CancelRequested: true, Note: "cancel requested"}, nil +} type controlBackend struct { *fakeBackend calls []string + parseTextUnitQueries []backendrpc.TextUnitQueryParams + parseErrorQueries []backendrpc.TextUnitQueryParams + translationTaskUpdates []backendrpc.TranslationTaskUpdateParams translationTaskListParams []backendrpc.TranslationTaskListParams localizedPublishParams []backendrpc.LocalizedPublishParams localizedRollbackParams []backendrpc.LocalizedRollbackParams @@ -629,8 +673,70 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil } +func (b *controlBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) { + b.calls = append(b.calls, "daemon.doctor") + if b.fakeBackend != nil { + return b.fakeBackend.DaemonDoctor(ctx) + } + return &backendrpc.DoctorReport{Command: "doctor", Status: "ok", Message: "healthy", Healthy: true}, nil +} + +func (b *controlBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) { + b.calls = append(b.calls, "daemon.logs") + if b.fakeBackend != nil { + return b.fakeBackend.DaemonLogs(ctx, tail) + } + return &backendrpc.LogsReport{Command: "logs", Status: "ok", Message: "ok", LogPath: "/tmp/bat-daemon.log", Exists: true, Empty: false, Bytes: 1, TotalLines: 1, ReturnedLines: 1, Content: "line-1\n"}, nil +} +func (b *controlBackend) TaskList(ctx context.Context) (*backendrpc.TaskList, error) { + b.calls = append(b.calls, "task.list") + if b.fakeBackend != nil { + return b.fakeBackend.TaskList(ctx) + } + return &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{}}, nil +} +func (b *controlBackend) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) { + b.calls = append(b.calls, "task.status") + if b.fakeBackend != nil { + return b.fakeBackend.TaskStatus(ctx, taskID) + } + return &backendrpc.TaskRecord{ID: taskID, Kind: "resource.sync", Status: "running"}, nil +} +func (b *controlBackend) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) { + b.calls = append(b.calls, "task.logs") + if b.fakeBackend != nil { + return b.fakeBackend.TaskLogs(ctx, taskID) + } + return &backendrpc.TaskLogs{TaskID: taskID, Lines: []string{"task-log-line-1"}}, nil +} +func (b *controlBackend) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) { + b.calls = append(b.calls, "task.cancel") + if b.fakeBackend != nil { + return b.fakeBackend.TaskCancel(ctx, taskID) + } + return &backendrpc.TaskCancelResult{TaskID: taskID, CancelRequested: true, Note: "cancel requested"}, nil +} + +func (b *controlBackend) ParseStatus(ctx context.Context) (json.RawMessage, error) { + b.calls = append(b.calls, "parse.status") + return json.RawMessage(`{"available":true,"status":"parsed"}`), nil +} + +func (b *controlBackend) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) { + b.calls = append(b.calls, "parse.text_units") + b.parseTextUnitQueries = append(b.parseTextUnitQueries, query) + return json.RawMessage(`{"available":true,"entries":[{"id":"direct:a#unit:0","destination":"Bundle/a.bundle","source_text":"source","field_path":"Scenario.Message"}]}`), nil +} + +func (b *controlBackend) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) { + b.calls = append(b.calls, "parse.errors") + b.parseErrorQueries = append(b.parseErrorQueries, query) + return json.RawMessage(`{"available":true,"entries":[]}`), nil +} + func (b *controlBackend) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) { b.calls = append(b.calls, "translation.task.update") + b.translationTaskUpdates = append(b.translationTaskUpdates, params) return json.RawMessage(`{"task_status":"` + params.Status + `"}`), nil } @@ -1392,6 +1498,13 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) { if !strings.Contains(rr.Body.String(), "/v1/launcher/bootstrap") { t.Fatalf("openapi missing launcher bootstrap") } + if !strings.Contains(rr.Body.String(), "/admin/dashboard/") || + !strings.Contains(rr.Body.String(), "/admin/tasks") || + !strings.Contains(rr.Body.String(), "/admin/parse/text-units") || + !strings.Contains(rr.Body.String(), "translation_results") || + !strings.Contains(rr.Body.String(), "task-cancel") { + t.Fatalf("openapi missing dashboard/task admin routes") + } rr = httptest.NewRecorder() s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/", nil)) @@ -1410,11 +1523,187 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) { } links := strings.Join(admin.Links, "\n") if !strings.Contains(links, "/admin/translation/tasks") || - !strings.Contains(links, "/admin/translation/handoff") { + !strings.Contains(links, "/admin/translation/handoff") || + !strings.Contains(links, "/admin/dashboard/") || + !strings.Contains(links, "/admin/parse/text-units") || + !strings.Contains(links, "/admin/tasks/logs") { t.Fatalf("admin links=%v", admin.Links) } } +func TestAdminDashboardServesStaticAssetsWithoutAdminToken(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = "control-token" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + s := NewServer(cfg, nil, nil) + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/dashboard/", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("dashboard status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("dashboard Cache-Control=%q", rr.Header().Get("Cache-Control")) + } + if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "connect-src") { + t.Fatalf("dashboard CSP=%q", rr.Header().Get("Content-Security-Policy")) + } + if !strings.Contains(rr.Body.String(), "bat-api Dashboard") { + t.Fatalf("dashboard body missing title") + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/dashboard/app.js", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("app.js status=%d body=%s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "translation-worker-run") { + t.Fatalf("app.js missing dashboard control wiring") + } +} + +func TestAdminTaskAndDiagnosticsEndpointsProxyAuthenticatedRequests(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = "control-token" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + message := "task running" + backend := &controlBackend{fakeBackend: &fakeBackend{ + doctor: &backendrpc.DoctorReport{ + Command: "doctor", + Status: "ok", + Message: "healthy", + Healthy: true, + Checks: []backendrpc.DoctorCheck{{Name: "socket", OK: true, Message: "ok"}}, + }, + taskList: &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{{ + ID: "task-sync-1", + Kind: "resource.sync", + Status: "running", + Message: &message, + }}}, + }} + s := NewServer(cfg, backend, nil) + + tests := []struct { + name string + method string + path string + wantCall string + }{ + {name: "diagnostics", method: http.MethodGet, path: "/admin/diagnostics", wantCall: "daemon.doctor"}, + {name: "daemon logs", method: http.MethodGet, path: "/admin/logs?tail=20", wantCall: "daemon.logs"}, + {name: "task list", method: http.MethodGet, path: "/admin/tasks", wantCall: "task.list"}, + {name: "task status", method: http.MethodGet, path: "/admin/tasks/status?task_id=task-sync-1", wantCall: "task.status"}, + {name: "task logs", method: http.MethodGet, path: "/admin/tasks/logs?task_id=task-sync-1", wantCall: "task.logs"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequest(tc.method, tc.path, nil) + request.Header.Set("Authorization", "Bearer control-token") + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.wantCall { + t.Fatalf("calls=%v", backend.calls) + } + }) + } + + request := httptest.NewRequest(http.MethodGet, "/admin/logs?tail=0", nil) + request.Header.Set("Authorization", "Bearer control-token") + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid logs status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/tasks/status", nil) + request.Header.Set("Authorization", "Bearer control-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid task status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/tasks", nil) + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated tasks status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestAdminParseEndpointsProxyAuthenticatedRequests(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = "parse-token" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + backend := &controlBackend{fakeBackend: &fakeBackend{}} + s := NewServer(cfg, backend, nil) + + tests := []struct { + name string + path string + wantCall string + }{ + {name: "parse status", path: "/admin/parse/status", wantCall: "parse.status"}, + {name: "parse text units", path: "/admin/parse/text-units?offset=1&limit=25&destination=Bundle%2Fa.bundle&path_id=42&class_id=114&field_path=Scenario.Message&format=plain", wantCall: "parse.text_units"}, + {name: "parse errors", path: "/admin/parse/errors?destination=Bundle%2Fa.bundle&limit=10", wantCall: "parse.errors"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, tc.path, nil) + request.Header.Set("Authorization", "Bearer parse-token") + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.wantCall { + t.Fatalf("calls=%v", backend.calls) + } + }) + } + if len(backend.parseTextUnitQueries) != 1 { + t.Fatalf("parse text unit queries=%#v", backend.parseTextUnitQueries) + } + query := backend.parseTextUnitQueries[0] + if query.Offset != 1 || + query.Limit != 25 || + query.Destination != "Bundle/a.bundle" || + query.PathID == nil || *query.PathID != 42 || + query.ClassID == nil || *query.ClassID != 114 || + query.FieldPath != "Scenario.Message" || + query.Format != "plain" { + t.Fatalf("query=%#v", query) + } + if len(backend.parseErrorQueries) != 1 || backend.parseErrorQueries[0].Destination != "Bundle/a.bundle" { + t.Fatalf("parse error queries=%#v", backend.parseErrorQueries) + } + + request := httptest.NewRequest(http.MethodGet, "/admin/parse/text-units?limit=0", nil) + request.Header.Set("Authorization", "Bearer parse-token") + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid parse query status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/parse/status", nil) + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated parse status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + func TestAdminControlForwardsAllowlistedActions(t *testing.T) { cfg := DefaultConfig() cfg.AuthToken = "control-token" @@ -1436,7 +1725,8 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { {name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"}, {name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"}, {name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"}, - {name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"failed","failure_reason":"provider rejected payload","provider_run_id":"provider-run-1"}`, rpcMethod: "translation.task.update", call: "translation.task.update"}, + {name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"}, + {name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"}, {name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"}, {name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"}, {name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"}, @@ -1464,6 +1754,13 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { }) } + if len(backend.translationTaskUpdates) != 1 || + backend.translationTaskUpdates[0].Provider != "manual" || + len(backend.translationTaskUpdates[0].TranslationResults) != 1 || + backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" { + t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates) + } + request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`)) request.Header.Set("Authorization", "Bearer control-token") recorder := httptest.NewRecorder() @@ -1472,6 +1769,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { t.Fatalf("invalid translation update status=%d body=%s", recorder.Code, recorder.Body.String()) } + request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":"textunit/v-current/Scenario","status":"failed","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`)) + request.Header.Set("Authorization", "Bearer control-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid translation result status=%d body=%s", recorder.Code, recorder.Body.String()) + } + request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-worker-run", strings.NewReader(`{"concurrency":0}`)) request.Header.Set("Authorization", "Bearer control-token") recorder = httptest.NewRecorder() diff --git a/internal/api/config.go b/internal/api/config.go index b44ebac..bc00cc5 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -122,7 +122,7 @@ func (c *Config) Normalize() error { if c.AuthQueryParam == "" { c.AuthQueryParam = "bat_token" } - c.AuthExemptPaths = normalizePathList(c.AuthExemptPaths) + c.AuthExemptPaths = normalizePathList(append(c.AuthExemptPaths, dashboardAuthExemptPaths()...)) if c.RateLimitRPS < 0 { return fmt.Errorf("rate limit rps must be >= 0") } diff --git a/internal/api/dashboard.go b/internal/api/dashboard.go new file mode 100644 index 0000000..d63bcd1 --- /dev/null +++ b/internal/api/dashboard.go @@ -0,0 +1,37 @@ +package api + +import ( + "net/http" + "strings" + + dashboard "bat-api/web" +) + +const adminDashboardPath = "/admin/dashboard" + +var adminDashboardFileServer = http.FileServer(http.FS(dashboard.Assets)) + +func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + w.Header().Set("Cache-Control", "no-store") + if r.URL.Path == adminDashboardPath { + http.Redirect(w, r, adminDashboardPath+"/", http.StatusMovedPermanently) + return + } + if !strings.HasPrefix(r.URL.Path, adminDashboardPath+"/") { + http.NotFound(w, r) + return + } + http.StripPrefix(adminDashboardPath+"/", adminDashboardFileServer).ServeHTTP(w, r) +} + +func dashboardAuthExemptPaths() []string { + return []string{adminDashboardPath, adminDashboardPath + "/"} +} + +func isAdminDashboardPath(path string) bool { + return path == adminDashboardPath || strings.HasPrefix(path, adminDashboardPath+"/") +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index b7d6355..1d18d12 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -51,7 +51,11 @@ func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") - w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + if isAdminDashboardPath(r.URL.Path) { + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' http: https:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'") + } else { + w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + } next.ServeHTTP(w, r) }) } diff --git a/internal/api/openapi.go b/internal/api/openapi.go index f5ccb16..ff0b99d 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -109,12 +109,210 @@ paths: responses: "200": description: OpenAPI YAML. + /admin/dashboard/: + get: + summary: Embedded bat-api dashboard + security: [] + responses: + "200": + description: Static dashboard HTML. /admin/: get: summary: Admin control entry responses: "200": description: Admin links and allowlisted control actions. + /admin/diagnostics: + get: + summary: Read Rust daemon doctor diagnostics + responses: + "200": + description: Current daemon.doctor report. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat diagnostics backend is unavailable. + /admin/logs: + get: + summary: Read Rust daemon log tail + parameters: + - name: tail + in: query + schema: + type: integer + minimum: 1 + maximum: 2000 + responses: + "200": + description: Current daemon.logs report. + "400": + description: Invalid log query. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat log backend is unavailable. + /admin/tasks: + get: + summary: List Rust-owned async daemon tasks + responses: + "200": + description: Current task.list report. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat task backend is unavailable. + /admin/tasks/status: + get: + summary: Read one Rust-owned async daemon task + parameters: + - name: task_id + in: query + required: true + schema: + type: string + responses: + "200": + description: Current task.status report. + "400": + description: Missing or invalid task_id. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat task backend is unavailable. + /admin/tasks/logs: + get: + summary: Read one Rust-owned async daemon task log + parameters: + - name: task_id + in: query + required: true + schema: + type: string + responses: + "200": + description: Current task.logs report. + "400": + description: Missing or invalid task_id. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat task backend is unavailable. + /admin/parse/status: + get: + summary: Read Rust-owned parse/TextUnit index status + responses: + "200": + description: Current parse.status report. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat parse backend is unavailable. + /admin/parse/text-units: + get: + summary: Query Rust-owned TextUnit index entries + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + - name: destination + in: query + schema: + type: string + - name: path_pattern + in: query + schema: + type: string + - name: archive_entry + in: query + schema: + type: string + - name: path_id + in: query + schema: + type: integer + format: int64 + - name: class_id + in: query + schema: + type: integer + - name: field_path + in: query + schema: + type: string + - name: format + in: query + schema: + type: string + responses: + "200": + description: Current parse.text_units report. + "400": + description: Invalid TextUnit query. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat parse backend is unavailable. + /admin/parse/errors: + get: + summary: Query Rust-owned TextUnit extraction diagnostics + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + - name: destination + in: query + schema: + type: string + - name: path_pattern + in: query + schema: + type: string + - name: archive_entry + in: query + schema: + type: string + - name: path_id + in: query + schema: + type: integer + format: int64 + - name: class_id + in: query + schema: + type: integer + - name: field_path + in: query + schema: + type: string + - name: format + in: query + schema: + type: string + responses: + "200": + description: Current parse.errors report. + "400": + description: Invalid parse error query. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat parse backend is unavailable. /admin/schedules: get: summary: List Rust-owned resource workflow schedules @@ -232,7 +430,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] requestBody: required: false content: @@ -285,7 +483,19 @@ paths: type: string provider: type: string - enum: [mock, crowdin] + translation_results: + type: array + items: + type: object + required: [unit_id, source_text, translated_text] + additionalProperties: false + properties: + unit_id: + type: string + source_text: + type: string + translated_text: + type: string fixture_path: type: string concurrency: diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 54c79a4..d3ed632 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -39,6 +39,29 @@ type ControlBackend interface { CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) } +// DaemonLogsBackend exposes the Rust daemon log tail for an authenticated +// dashboard. It remains read-only and never opens log files from Go. +type DaemonLogsBackend interface { + DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) +} + +// TaskBackend exposes Rust-owned async task state to the dashboard. Go only +// forwards read/cancel requests and does not create generic tasks. +type TaskBackend interface { + TaskList(ctx context.Context) (*backendrpc.TaskList, error) + TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) + TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) + TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) +} + +// ParseBackend exposes existing Rust TextUnit index queries to the dashboard. +// It is read-only and does not expand parser coverage. +type ParseBackend interface { + ParseStatus(ctx context.Context) (json.RawMessage, error) + ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) + ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) +} + // ScheduleBackend exposes the Rust-owned schedule store to an authenticated // dashboard. The JSON result remains Rust's report shape so the API does not // duplicate schedule state or invent a second schema. @@ -97,6 +120,9 @@ func (r RPCClient) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) { func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) { return r.Client.DaemonRefresh(ctx, force) } +func (r RPCClient) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) { + return r.Client.DaemonLogs(ctx, tail) +} func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { return r.Client.ResourceSync(ctx, force) } @@ -109,6 +135,18 @@ func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { return r.Client.CatalogRefresh(ctx, force) } +func (r RPCClient) TaskList(ctx context.Context) (*backendrpc.TaskList, error) { + return r.Client.TaskList(ctx) +} +func (r RPCClient) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) { + return r.Client.TaskStatus(ctx, taskID) +} +func (r RPCClient) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) { + return r.Client.TaskLogs(ctx, taskID) +} +func (r RPCClient) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) { + return r.Client.TaskCancel(ctx, taskID) +} func (r RPCClient) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) { return r.Client.ScheduleListFiltered(ctx, params) } diff --git a/internal/api/server.go b/internal/api/server.go index 8a1a2fb..1e88f59 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -61,6 +61,16 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig) mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap) mux.HandleFunc("/openapi.yaml", s.handleOpenAPI) + mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard) + mux.HandleFunc("/admin/dashboard/", s.handleAdminDashboard) + mux.HandleFunc("/admin/diagnostics", s.handleAdminDiagnostics) + mux.HandleFunc("/admin/logs", s.handleAdminLogs) + mux.HandleFunc("/admin/tasks", s.handleAdminTasks) + mux.HandleFunc("/admin/tasks/status", s.handleAdminTaskStatus) + mux.HandleFunc("/admin/tasks/logs", s.handleAdminTaskLogs) + mux.HandleFunc("/admin/parse/status", s.handleAdminParseStatus) + mux.HandleFunc("/admin/parse/text-units", s.handleAdminParseTextUnits) + mux.HandleFunc("/admin/parse/errors", s.handleAdminParseErrors) mux.HandleFunc("/admin/schedules", s.handleAdminSchedules) mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks) mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff) @@ -128,7 +138,16 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/" + ClientPatchHost + "/...", "/" + ServerInfoHost + "/...", "/openapi.yaml", + "/admin/dashboard/", "/admin/", + "/admin/diagnostics", + "/admin/logs", + "/admin/tasks", + "/admin/tasks/status", + "/admin/tasks/logs", + "/admin/parse/status", + "/admin/parse/text-units", + "/admin/parse/errors", "/admin/schedules", "/admin/translation/tasks", "/admin/translation/handoff", diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 454e836..196cf53 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -264,13 +264,24 @@ type ScheduleRunParams struct { MaxRuns *uint64 `json:"max_runs,omitempty"` } +// TranslationTaskUnitResultParam is the dashboard/manual-review subset of one +// TextUnit result accepted by Rust translation.task.update. +type TranslationTaskUnitResultParam struct { + UnitID string `json:"unit_id"` + SourceText string `json:"source_text"` + TranslatedText string `json:"translated_text"` +} + // TranslationTaskUpdateParams is used by translation.task.update to persist -// provider worker state for one task in the current official release. +// provider worker state and optional manual-review text for one task in the +// current official release. type TranslationTaskUpdateParams struct { - TaskID string `json:"task_id"` - Status string `json:"status"` - FailureReason string `json:"failure_reason,omitempty"` - ProviderRunID string `json:"provider_run_id,omitempty"` + TaskID string `json:"task_id"` + Status string `json:"status"` + FailureReason string `json:"failure_reason,omitempty"` + ProviderRunID string `json:"provider_run_id,omitempty"` + Provider string `json:"provider,omitempty"` + TranslationResults []TranslationTaskUnitResultParam `json:"translation_results,omitempty"` } // TranslationTaskListParams filters the Rust-owned translation task queue. diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index 8319e95..5e0efb5 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -434,9 +434,14 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) { t.Fatalf("decode params: %v", err) } if params.TaskID != "textunit/v-current/Scenario" || - params.Status != "failed" || - params.FailureReason != "provider rejected payload" || - params.ProviderRunID != "provider-run-1" { + params.Status != "completed" || + params.FailureReason != "" || + params.ProviderRunID != "provider-run-1" || + params.Provider != "manual" || + len(params.TranslationResults) != 1 || + params.TranslationResults[0].UnitID != "direct:a#unit:0" || + params.TranslationResults[0].SourceText != "source" || + params.TranslationResults[0].TranslatedText != "译文" { t.Fatalf("params = %#v", params) } return testResponse{ @@ -444,16 +449,21 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) { OK: true, Status: "ok", RequestID: "req-test-translation-update", - Data: map[string]any{"task_status": "failed"}, + Data: map[string]any{"task_status": "completed"}, }, } }) raw, err := client.TranslationTaskUpdate(context.Background(), TranslationTaskUpdateParams{ TaskID: "textunit/v-current/Scenario", - Status: "failed", - FailureReason: "provider rejected payload", + Status: "completed", ProviderRunID: "provider-run-1", + Provider: "manual", + TranslationResults: []TranslationTaskUnitResultParam{{ + UnitID: "direct:a#unit:0", + SourceText: "source", + TranslatedText: "译文", + }}, }) if err != nil { t.Fatalf("TranslationTaskUpdate error: %v", err) diff --git a/scripts/check-doc-status.sh b/scripts/check-doc-status.sh index 8ed8180..615c63f 100644 --- a/scripts/check-doc-status.sh +++ b/scripts/check-doc-status.sh @@ -55,8 +55,6 @@ placeholder_readmes=( "pkg/cas/README.md" "pkg/translator/README.md" "pkg/types/README.md" - "web/README.md" - "web/admin/README.md" "web/shared/README.md" ) diff --git a/web/admin/README.md b/web/admin/README.md index bc309d0..84afa31 100644 --- a/web/admin/README.md +++ b/web/admin/README.md @@ -1,17 +1,28 @@ -# bat-api Admin Panel +# bat-api Dashboard -本目录预留给后续 `bat-api` 管理面板。当前后端已提供只读入口 -`GET /admin/`,返回 health、ready、bootstrap、release、resources 和 OpenAPI -链接。 +当前稳定交付的 dashboard 静态资产位于 `web/` 根目录,并由 Go embed 挂载到 +`GET /admin/dashboard/`。本目录只保留后续完整管理后台的说明入口。`GET /admin/` +仍返回 health、ready、bootstrap、release、resources、OpenAPI、dashboard 和可用 +控制链接。 需要配置 `BAT_API_AUTH_TOKEN` 的接口: +- `GET /admin/diagnostics`:转发 `daemon.doctor`,读取 daemon 诊断。 +- `GET /admin/logs`:转发 `daemon.logs`,按 `tail` 读取 daemon 日志尾部。 +- `GET /admin/tasks`、`GET /admin/tasks/status`、`GET /admin/tasks/logs`: + 转发 `task.*` 查询任务列表、单项状态和任务日志。 - `GET /admin/schedules`:转发 Rust 持有的 schedule 查询,支持 `id`、`group` 和 `enabled` 过滤。 - `POST /admin/control/schedule-{add,update,remove,run}`:转发 `schedule.*` 计划控制。 +- `GET /admin/parse/status`、`GET /admin/parse/text-units`、 + `GET /admin/parse/errors`:转发 `parse.*` 只读查询当前 release 的解析状态、 + TextUnit 明细和解析错误。 +- `POST /admin/control/task-cancel`:转发 `task.cancel`,请求字段为 `task_id`。 - `POST /admin/control/translation-task-update`:转发 - `translation.task.update`,供外部 provider 流程回写任务状态。 + `translation.task.update`,供外部 provider 流程或人工校对流程回写任务状态; + 当 `status=completed` 时可带 `provider`、`provider_run_id` 和 + `translation_results`。 - `POST /admin/control/translation-worker-run`:转发 `translation.worker.run`,触发 Rust provider worker。 - `POST /admin/control/translation-proofread`:转发 @@ -27,6 +38,6 @@ - `GET /admin/translation/handoff`:转发 `translation.handoff`,读取当前 release 的完整翻译交接视图。 -正式前端必须复用资源 API 的 HTTP 鉴权、限流、访问日志、反代处理和动态响应 -`Cache-Control: no-store` 策略。静态前端资产仍为 not implemented,尚未作为 -本目录的稳定交付物。当前组件边界见 `docs/reports/GO_STATUS.md`。 +内嵌 dashboard 自身允许免 token 读取静态资产;所有写操作和 Rust 状态查询仍复用 +资源 API 的 HTTP 鉴权、限流、访问日志、反代处理和动态响应 `Cache-Control: no-store` +策略。当前组件边界见 `docs/reports/GO_STATUS.md`。 diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..0af6587 --- /dev/null +++ b/web/app.js @@ -0,0 +1,861 @@ +'use strict'; + +const $ = (id) => document.getElementById(id); +const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + +const app = { + baseUrl: defaultBaseUrl(), + token: localStorage.getItem('bat-api-token') || '', + rememberToken: localStorage.getItem('bat-api-remember-token') === 'true', + refreshTimer: null, + resources: { offset: 0, limit: 50, total: 0, items: [], filter: '' }, + schedules: [], + tasks: [], + selectedDaemonTaskId: '', + translation: { tasks: [], selectedTask: null, unitRows: [] }, +}; + +function defaultBaseUrl() { + const saved = localStorage.getItem('bat-api-base-url'); + if (saved) return saved; + if (window.location.protocol === 'http:' || window.location.protocol === 'https:') { + return window.location.origin; + } + return 'http://localhost:8080'; +} + +function html(value) { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[char]); +} + +function attr(value) { + return html(value).replace(/`/g, '`'); +} + +function text(id, value) { + const node = $(id); + if (node) node.textContent = value ?? '-'; +} + +function setHTML(id, value) { + const node = $(id); + if (node) node.innerHTML = value; +} + +function apiBase() { + return (app.baseUrl || window.location.origin).replace(/\/+$/, ''); +} + +async function requestJSON(path, options = {}) { + const headers = { ...(options.headers || {}) }; + if (app.token) headers.Authorization = `Bearer ${app.token}`; + if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; + const response = await fetch(apiBase() + path, { ...options, headers }); + const body = await response.text(); + let parsed = {}; + if (body) { + try { + parsed = JSON.parse(body); + } catch (error) { + throw new Error(`${path} 返回了非 JSON 响应`); + } + } + if (!response.ok) { + const detail = parsed.error?.message || parsed.message || response.statusText; + throw new Error(`${path} HTTP ${response.status}: ${detail}`); + } + return parsed; +} + +function postControl(action, payload = {}) { + return requestJSON(`/admin/control/${action}`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +function requireToken(scope) { + if (app.token) return true; + toast(`${scope} 需要 admin token`, 'warn'); + return false; +} + +function fmtBytes(value) { + if (value === null || value === undefined) return '-'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = Number(value); + let idx = 0; + while (size >= 1024 && idx < units.length - 1) { + size /= 1024; + idx += 1; + } + const digits = idx === 0 || size >= 10 ? 0 : 1; + return `${size.toFixed(digits)} ${units[idx]}`; +} + +function fmtTime(seconds) { + if (!seconds) return '-'; + const date = new Date(Number(seconds) * 1000); + return Number.isNaN(date.getTime()) ? String(seconds) : date.toLocaleString(); +} + +function shortHash(value) { + if (!value) return '-'; + const str = String(value); + return str.length > 18 ? `${str.slice(0, 10)}...${str.slice(-6)}` : str; +} + +function statusPill(status) { + const value = String(status || 'unknown'); + const tone = /(ok|ready|completed|published|healthy|success|translated)/i.test(value) + ? 'ok' + : /(fail|error|missing|unavailable|rejected)/i.test(value) + ? 'bad' + : /(run|sync|queued|review|proofreading|progress)/i.test(value) + ? 'warn' + : 'neutral'; + return `${html(value)}`; +} + +function emptyRow(columns, message) { + return `${html(message)}`; +} + +function toast(message, tone = 'ok') { + const item = document.createElement('div'); + item.className = `toast toast-${tone}`; + item.textContent = message; + $('toastRegion').appendChild(item); + window.setTimeout(() => item.remove(), 4200); +} + +function setConnection(status, detail) { + const dot = $('connectionDot'); + dot.className = `state-dot state-${status}`; + text('connectionTitle', status === 'ok' ? '已连接' : status === 'warn' ? '需要 token' : status === 'loading' ? '刷新中' : '未连接'); + text('connectionSubtitle', detail || apiBase()); +} + +function setAdminPlaceholders() { + text('metricLocalized', 'locked'); + text('metricLocalizedNote', '需要 admin token'); + text('taskSummary', '需要 admin token'); + setHTML('dashboardTaskList', '
需要 admin token
'); + text('scheduleSummary', '需要 admin token'); + setHTML('scheduleList', '
需要 admin token
'); + text('translationTaskSummary', '需要 admin token'); + setHTML('translationTaskRows', emptyRow(4, '需要 admin token')); + text('daemonTaskSummary', '需要 admin token'); + setHTML('daemonTaskRows', emptyRow(5, '需要 admin token')); + text('doctorSummary', '需要 admin token'); + setHTML('doctorChecks', '
需要 admin token
'); + text('logSummary', '需要 admin token'); + text('daemonLogBox', ''); +} + +async function refreshAll() { + setConnection('loading', apiBase()); + await Promise.allSettled([loadHealthAndRelease(), loadResources(true)]); + if (!app.token) { + setConnection('warn', '未配置 admin token'); + setAdminPlaceholders(); + return; + } + await Promise.allSettled([ + loadSchedules(), + loadDaemonTasks(), + loadDoctor(), + loadDaemonLogs(), + loadTranslationStatus(), + loadTranslationTasks(), + loadHandoff(), + ]); +} + +async function loadHealthAndRelease() { + try { + const health = await requestJSON('/healthz'); + text('metricHealth', health.ready ? 'ready' : 'not ready'); + text('metricHealthNote', health.source || 'healthz'); + text('metricRpc', health.rpc_available ? 'connected' : 'offline'); + text('metricRpcNote', health.socket || '-'); + text('metricResources', `${health.present_count ?? 0}/${health.entry_count ?? 0}`); + text('metricResourcesNote', `${health.missing_count ?? 0} missing`); + setConnection('ok', apiBase()); + } catch (error) { + setConnection('bad', error.message); + toast(error.message, 'bad'); + } + + try { + const release = await requestJSON('/v1/release'); + const snap = release.snapshot || {}; + text('releaseSummary', `${snap.app_version || '-'} / ${snap.bundle_version || '-'} / ${release.source || '-'}`); + renderKV('releaseDetails', { + version_id: snap.version_id, + status_code: snap.status_code || release.status_code, + distribution_status_code: snap.distribution_status_code, + resource_root: release.resource_root, + addressables_root: snap.addressables_root, + manifest_version: release.manifest_version, + entry_count: release.entry_count, + present_count: release.present_count, + missing_count: release.missing_count, + rpc_available: release.rpc_available, + doctor_healthy: release.doctor_healthy, + }); + } catch (error) { + text('releaseSummary', error.message); + setHTML('releaseDetails', ''); + } +} + +function renderKV(id, values) { + const rows = Object.entries(values) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `
${html(key)}
${html(String(value))}
`) + .join(''); + setHTML(id, rows || '
无数据
'); +} + +async function loadResources(sampleOnly = false) { + const limit = sampleOnly ? 8 : app.resources.limit; + const offset = sampleOnly ? 0 : app.resources.offset; + try { + const data = await requestJSON(`/v1/resources?offset=${offset}&limit=${limit}`); + const items = data.items || []; + if (sampleOnly) { + setHTML('dashboardResourceRows', renderResourceRows(items)); + text('resourceSampleSummary', `${items.length}/${data.total ?? items.length}`); + return; + } + app.resources.items = items; + app.resources.total = data.total ?? items.length; + text('resourceListSummary', `${app.resources.total} entries`); + renderResourceList(); + } catch (error) { + const target = sampleOnly ? 'dashboardResourceRows' : 'resourceRows'; + setHTML(target, emptyRow(4, error.message)); + if (!sampleOnly) text('resourceListSummary', error.message); + } +} + +function renderResourceRows(items) { + if (!items.length) return emptyRow(4, '没有资源记录'); + return items.map((item) => ` + + ${html(item.relative_path || item.destination || '-')} + ${html(fmtBytes(item.bytes))} + ${statusPill(item.present && item.size_match ? 'present' : item.present ? 'size_mismatch' : 'missing')} + ${html(shortHash(item.blake3))} + + `).join(''); +} + +function renderResourceList() { + const needle = app.resources.filter.toLowerCase(); + const items = needle + ? app.resources.items.filter((item) => String(item.relative_path || item.destination || '').toLowerCase().includes(needle)) + : app.resources.items; + setHTML('resourceRows', renderResourceRows(items)); + const start = app.resources.total === 0 ? 0 : app.resources.offset + 1; + const end = Math.min(app.resources.offset + app.resources.items.length, app.resources.total); + text('resourcePagerText', `${start}-${end} / ${app.resources.total}`); + $('resourcePrevBtn').disabled = app.resources.offset === 0; + $('resourceNextBtn').disabled = app.resources.offset + app.resources.limit >= app.resources.total; +} + +async function loadSchedules() { + if (!app.token) return setAdminPlaceholders(); + const query = new URLSearchParams(); + const id = $('scheduleIdFilter').value.trim(); + const group = $('scheduleGroupFilter').value.trim(); + const enabled = $('scheduleEnabledFilter').value; + if (id) query.set('id', id); + if (group) query.set('group', group); + if (enabled) query.set('enabled', enabled); + try { + const data = await requestJSON(`/admin/schedules${query.size ? `?${query}` : ''}`); + app.schedules = normalizeList(data, ['schedules', 'items', 'entries']); + text('scheduleSummary', `${app.schedules.length} schedules`); + setHTML('scheduleList', renderScheduleItems(app.schedules)); + } catch (error) { + text('scheduleSummary', error.message); + setHTML('scheduleList', `
${html(error.message)}
`); + } +} + +function normalizeList(data, keys) { + if (Array.isArray(data)) return data; + for (const key of keys) { + if (Array.isArray(data?.[key])) return data[key]; + } + return []; +} + +function renderScheduleItems(items) { + if (!items.length) return '
没有计划
'; + return items.map((item) => { + const extras = [ + item.every_seconds ? `every ${item.every_seconds}s` : '', + item.next_run_unix_seconds ? `next ${fmtTime(item.next_run_unix_seconds)}` : '', + item.remaining_count ? `left ${item.remaining_count}` : '', + ].filter(Boolean).join(' / '); + return ` +
+
+ ${html(item.id || '-')} +
${html(item.group || '-')} ${extras ? `/ ${html(extras)}` : ''}
+
+
+ ${statusPill(item.enabled ? 'enabled' : 'disabled')} + + +
+
+ `; + }).join(''); +} + +function schedulePayload() { + const id = $('scheduleIdInput').value.trim(); + if (!id) throw new Error('schedule id 不能为空'); + const payload = { id, enabled: $('scheduleEnabledInput').checked }; + const group = $('scheduleGroupInput').value.trim(); + const action = $('scheduleActionInput').value.trim(); + const args = $('scheduleArgsInput').value.split(/\n|,/).map((arg) => arg.trim()).filter(Boolean); + if (group) payload.group = group; + if (action) payload.action = action; + if (args.length) payload.args = args; + for (const [field, idName] of [ + ['delay_seconds', 'scheduleDelayInput'], + ['every_seconds', 'scheduleEveryInput'], + ['count', 'scheduleCountInput'], + ['next_run_unix_seconds', 'scheduleNextRunInput'], + ]) { + const raw = $(idName).value.trim(); + if (raw) payload[field] = Number(raw); + } + return payload; +} + +async function mutateSchedule(action, payload) { + if (!requireToken('调度')) return; + try { + const result = await postControl(action, payload); + toast(`${action} accepted: ${result.result?.task_id || result.rpc_method || 'ok'}`); + await loadSchedules(); + } catch (error) { + toast(error.message, 'bad'); + } +} + +async function loadDaemonTasks() { + if (!app.token) return setAdminPlaceholders(); + try { + const data = await requestJSON('/admin/tasks'); + app.tasks = data.tasks || []; + text('daemonTaskSummary', `${app.tasks.length} tasks`); + text('taskSummary', `${app.tasks.length} tasks`); + setHTML('daemonTaskRows', renderDaemonTaskRows(app.tasks)); + setHTML('dashboardTaskList', renderDashboardTaskList(app.tasks.slice(0, 5))); + } catch (error) { + text('daemonTaskSummary', error.message); + text('taskSummary', error.message); + setHTML('daemonTaskRows', emptyRow(5, error.message)); + setHTML('dashboardTaskList', `
${html(error.message)}
`); + } +} + +function renderDaemonTaskRows(tasks) { + if (!tasks.length) return emptyRow(5, '没有任务记录'); + return tasks.map((task) => ` + + ${html(task.id)} + ${html(task.kind || '-')} + ${statusPill(task.status)} + ${html(task.stage || '-')} + ${html(fmtTime(task.updated_at))} + + `).join(''); +} + +function renderDashboardTaskList(tasks) { + if (!tasks.length) return '
没有任务记录
'; + return tasks.map((task) => ` +
+
+ ${html(task.kind || task.id)} +
${html(task.id)} / ${html(task.stage || '-')}
+
+ ${statusPill(task.status)} +
+ `).join(''); +} + +async function selectDaemonTask(taskId) { + app.selectedDaemonTaskId = taskId; + text('daemonTaskDetailSummary', taskId); + await Promise.allSettled([loadDaemonTaskStatus(taskId), loadDaemonTaskLogs(taskId)]); +} + +async function loadDaemonTaskStatus(taskId) { + try { + const task = await requestJSON(`/admin/tasks/status?task_id=${encodeURIComponent(taskId)}`); + renderKV('daemonTaskDetails', { + id: task.id, + kind: task.kind, + status: task.status, + stage: task.stage, + message: task.message, + created_at: fmtTime(task.created_at), + updated_at: fmtTime(task.updated_at), + started_at: fmtTime(task.started_at), + finished_at: fmtTime(task.finished_at), + error: task.error?.message, + }); + } catch (error) { + setHTML('daemonTaskDetails', `
${html(error.message)}
`); + } +} + +async function loadDaemonTaskLogs(taskId) { + try { + const logs = await requestJSON(`/admin/tasks/logs?task_id=${encodeURIComponent(taskId)}`); + text('daemonTaskLogBox', (logs.lines || []).join('\n')); + } catch (error) { + text('daemonTaskLogBox', error.message); + } +} + +async function loadDoctor() { + if (!app.token) return setAdminPlaceholders(); + try { + const data = await requestJSON('/admin/diagnostics'); + text('doctorSummary', data.healthy ? 'healthy' : (data.status || 'unhealthy')); + const checks = data.checks || []; + setHTML('doctorChecks', checks.length ? checks.map((check) => ` +
+
+ ${html(check.name || '-')} +
${html(check.message || '-')}
+
+ ${statusPill(check.ok ? 'ok' : 'failed')} +
+ `).join('') : '
没有诊断项
'); + } catch (error) { + text('doctorSummary', error.message); + setHTML('doctorChecks', `
${html(error.message)}
`); + } +} + +async function loadDaemonLogs() { + if (!app.token) return setAdminPlaceholders(); + const tail = Math.min(2000, Math.max(1, Number($('logTailInput').value || 200))); + try { + const data = await requestJSON(`/admin/logs?tail=${tail}`); + text('logSummary', `${data.returned_lines ?? 0}/${data.total_lines ?? 0} lines`); + text('daemonLogBox', data.content || ''); + } catch (error) { + text('logSummary', error.message); + text('daemonLogBox', error.message); + } +} + +async function loadTranslationStatus() { + if (!app.token) return setAdminPlaceholders(); + try { + const data = await requestJSON('/admin/translation/status'); + const code = data.status_code || data.localized_status_code || data.localized_release_status || data.status || 'unknown'; + text('metricLocalized', code); + text('metricLocalizedNote', data.current_localized_release_id || data.localized_release_id || data.message || '-'); + text('localizedSummary', `${code} / ${data.localized_release_id || data.current_localized_release_id || '-'}`); + } catch (error) { + text('metricLocalized', 'error'); + text('metricLocalizedNote', error.message); + text('localizedSummary', error.message); + } +} + +async function loadHandoff() { + if (!app.token) return setAdminPlaceholders(); + try { + const data = await requestJSON('/admin/translation/handoff'); + const handoff = data.handoff || data; + text('handoffSummary', handoff.job?.status || data.status || (data.available === false ? 'unavailable' : 'available')); + renderKV('handoffDetails', { + available: data.available, + current_version_id: data.current_version_id, + job_id: handoff.job?.job_id, + job_status: handoff.job?.status, + unit_count: handoff.job?.unit_count, + provider_runs: handoff.provider_runs?.length, + updated_unix_seconds: fmtTime(handoff.job?.updated_unix_seconds), + }); + } catch (error) { + text('handoffSummary', error.message); + setHTML('handoffDetails', ''); + } +} + +async function loadTranslationTasks() { + if (!app.token) return setAdminPlaceholders(); + const query = new URLSearchParams({ offset: '0', limit: '50' }); + const workerStatus = $('translationStatusFilter').value; + const destination = $('translationDestinationFilter').value.trim(); + const taskId = $('translationTaskFilter').value.trim(); + if (workerStatus) query.set('worker_status', workerStatus); + if (destination) query.set('destination', destination); + if (taskId) query.set('task_id', taskId); + try { + const data = await requestJSON(`/admin/translation/tasks?${query}`); + app.translation.tasks = data.entries || []; + text('translationTaskSummary', `${data.total_entries ?? app.translation.tasks.length} tasks`); + setHTML('translationTaskRows', renderTranslationTaskRows(app.translation.tasks)); + } catch (error) { + text('translationTaskSummary', error.message); + setHTML('translationTaskRows', emptyRow(4, error.message)); + } +} + +function taskCore(entry) { + return entry?.task || entry || {}; +} + +function taskID(entry) { + return taskCore(entry).task_id || entry?.task_id || entry?.unit_id || ''; +} + +function renderTranslationTaskRows(tasks) { + if (!tasks.length) return emptyRow(4, '没有翻译任务'); + return tasks.map((entry) => { + const task = taskCore(entry); + const resultCount = (entry.translation_results || task.translation_results || []).length; + return ` + + ${html(taskID(entry))} + ${html(task.destination || '-')}
${html(task.archive_entry || '')}
+ ${statusPill(entry.task_status || entry.worker_status || task.status)} + ${html(String(resultCount))} + + `; + }).join(''); +} + +async function selectTranslationTask(entry) { + app.translation.selectedTask = entry; + const task = taskCore(entry); + text('translationDetailSummary', taskID(entry)); + $('manualProviderRunInput').value = entry.provider_run_id || ''; + await loadTextUnitsForTask(entry); +} + +async function loadTextUnitsForTask(entry) { + const task = taskCore(entry); + const existingResults = new Map((entry.translation_results || []).map((result) => [result.unit_id, result])); + let units = []; + if (task.destination) { + const query = new URLSearchParams({ destination: task.destination, limit: '1000' }); + if (task.archive_entry) query.set('archive_entry', task.archive_entry); + try { + const data = await requestJSON(`/admin/parse/text-units?${query}`); + units = data.entries || []; + } catch (error) { + toast(error.message, 'bad'); + } + } + if (!units.length && existingResults.size) { + units = Array.from(existingResults.values()).map((result) => ({ + id: result.unit_id, + source_text: result.source_text, + destination: task.destination, + archive_entry: task.archive_entry, + })); + } + app.translation.unitRows = units.map((unit) => { + const id = unit.id || unit.unit_id; + const existing = existingResults.get(id) || {}; + return { + id, + sourceText: unit.source_text || existing.source_text || '', + translatedText: existing.translated_text || '', + destination: unit.destination || task.destination || '', + archiveEntry: unit.archive_entry || task.archive_entry || '', + serializedFile: unit.serialized_file || '', + pathID: unit.path_id, + classID: unit.class_id, + fieldPath: unit.field_path || '', + assetName: unit.asset_name || '', + format: unit.format || '', + }; + }); + renderUnitEditor(); +} + +function renderUnitEditor() { + const rows = app.translation.unitRows; + if (!app.translation.selectedTask) { + setHTML('unitEditor', '
选择一个翻译任务
'); + return; + } + if (!rows.length) { + setHTML('unitEditor', '
当前任务没有可显示 TextUnit 明细
'); + return; + } + setHTML('unitEditor', rows.map((row, index) => ` +
+
+
+ ${html(row.id)} +
${html(row.destination)}${row.archiveEntry ? ` / ${html(row.archiveEntry)}` : ''}
+
+ ${statusPill(row.translatedText ? 'reviewing' : 'empty')} +
+
+ field: ${html(row.fieldPath || '-')} + asset: ${html(row.assetName || '-')} + path_id: ${html(row.pathID ?? '-')} + class_id: ${html(row.classID ?? '-')} + format: ${html(row.format || '-')} +
+ + +
+ `).join('')); +} + +async function saveManualResults() { + if (!requireToken('人工校对')) return; + const entry = app.translation.selectedTask; + if (!entry) return toast('请选择翻译任务', 'warn'); + const results = $$('.unit-translation').map((editor) => { + const row = app.translation.unitRows[Number(editor.dataset.unitIndex)]; + return { + unit_id: row.id, + source_text: row.sourceText, + translated_text: editor.value, + }; + }).filter((result) => result.translated_text.trim() !== ''); + if (!results.length) return toast('没有可保存的译文', 'warn'); + const payload = { + task_id: taskID(entry), + status: 'completed', + provider: 'manual', + translation_results: results, + }; + const providerRunID = $('manualProviderRunInput').value.trim(); + if (providerRunID) payload.provider_run_id = providerRunID; + try { + await postControl('translation-task-update', payload); + toast(`已保存 ${results.length} 条校对结果`); + await Promise.allSettled([loadTranslationTasks(), loadHandoff(), loadTranslationStatus()]); + } catch (error) { + toast(error.message, 'bad'); + } +} + +async function runTranslationWorker() { + if (!requireToken('Provider worker')) return; + const payload = { + provider: $('workerProviderInput').value, + concurrency: Number($('workerConcurrencyInput').value || 8), + max_attempts: Number($('workerMaxAttemptsInput').value || 3), + lease_seconds: Number($('workerLeaseInput').value || 300), + retry_backoff_seconds: Number($('workerBackoffInput').value || 5), + }; + const maxTasks = $('workerMaxTasksInput').value.trim(); + const workerId = $('workerIdInput').value.trim(); + const fixture = $('workerFixtureInput').value.trim(); + if (maxTasks) payload.max_tasks = Number(maxTasks); + if (workerId) payload.worker_id = workerId; + if (fixture) payload.fixture_path = fixture; + try { + const result = await postControl('translation-worker-run', payload); + toast(`translation-worker-run accepted: ${result.result?.task_id || 'queued'}`); + await loadDaemonTasks(); + } catch (error) { + toast(error.message, 'bad'); + } +} + +async function publishLocalized() { + if (!requireToken('汉化发布')) return; + const payload = { from_worker: true, force: $('localizedForceInput').checked }; + const releaseID = $('localizedReleaseInput').value.trim(); + if (releaseID) payload.localized_release_id = releaseID; + try { + await postControl('localized-publish', payload); + toast('localized-publish accepted'); + await loadTranslationStatus(); + } catch (error) { + toast(error.message, 'bad'); + } +} + +async function rollbackLocalized() { + if (!requireToken('汉化回滚')) return; + const payload = {}; + const releaseID = $('localizedReleaseInput').value.trim(); + if (releaseID) payload.localized_release_id = releaseID; + if (!window.confirm('确认回滚当前汉化 release?')) return; + try { + await postControl('localized-rollback', payload); + toast('localized-rollback accepted'); + await loadTranslationStatus(); + } catch (error) { + toast(error.message, 'bad'); + } +} + +function switchView(view) { + $$('.nav-item').forEach((item) => item.classList.toggle('is-active', item.dataset.view === view)); + $$('.view').forEach((section) => section.classList.toggle('is-active', section.id === `view-${view}`)); + const active = $(`view-${view}`); + text('viewTitle', active?.dataset.title || view); + text('viewSubtitle', active?.dataset.subtitle || ''); + if (view === 'resources') loadResources(); + if (view === 'schedules') loadSchedules(); + if (view === 'translation') Promise.allSettled([loadTranslationTasks(), loadTranslationStatus(), loadHandoff()]); + if (view === 'tasks') loadDaemonTasks(); + if (view === 'logs') Promise.allSettled([loadDoctor(), loadDaemonLogs()]); +} + +function bindEvents() { + $$('.nav-item').forEach((item) => item.addEventListener('click', () => switchView(item.dataset.view))); + $$('[data-jump]').forEach((button) => button.addEventListener('click', () => switchView(button.dataset.jump))); + $('refreshAllBtn').addEventListener('click', refreshAll); + $('autoRefreshInput').addEventListener('change', configureAutoRefresh); + $('resourceLimitInput').addEventListener('change', () => { + app.resources.limit = Number($('resourceLimitInput').value || 50); + app.resources.offset = 0; + loadResources(); + }); + $('resourceFilterInput').addEventListener('input', () => { + app.resources.filter = $('resourceFilterInput').value.trim(); + renderResourceList(); + }); + $('resourcePrevBtn').addEventListener('click', () => { + app.resources.offset = Math.max(0, app.resources.offset - app.resources.limit); + loadResources(); + }); + $('resourceNextBtn').addEventListener('click', () => { + app.resources.offset += app.resources.limit; + loadResources(); + }); + $$('[data-control]').forEach((button) => button.addEventListener('click', async () => { + if (!requireToken(button.dataset.control)) return; + try { + const result = await postControl(button.dataset.control, button.dataset.control === 'sync' ? { force: false } : {}); + toast(`${button.dataset.control} accepted: ${result.result?.task_id || result.rpc_method || 'ok'}`); + await refreshAll(); + } catch (error) { + toast(error.message, 'bad'); + } + })); + + $('scheduleRefreshBtn').addEventListener('click', loadSchedules); + ['scheduleIdFilter', 'scheduleGroupFilter', 'scheduleEnabledFilter'].forEach((id) => $(id).addEventListener('input', loadSchedules)); + $('scheduleAddBtn').addEventListener('click', () => { + try { mutateSchedule('schedule-add', schedulePayload()); } catch (error) { toast(error.message, 'warn'); } + }); + $('scheduleUpdateBtn').addEventListener('click', () => { + try { mutateSchedule('schedule-update', schedulePayload()); } catch (error) { toast(error.message, 'warn'); } + }); + $('scheduleList').addEventListener('click', (event) => { + const runID = event.target.closest('[data-schedule-run]')?.dataset.scheduleRun; + const removeID = event.target.closest('[data-schedule-remove]')?.dataset.scheduleRemove; + if (runID) mutateSchedule('schedule-run', { id: runID, force: true }); + if (removeID && window.confirm(`删除计划 ${removeID}?`)) mutateSchedule('schedule-remove', { id: removeID }); + }); + + $('daemonTaskRefreshBtn').addEventListener('click', loadDaemonTasks); + $('daemonTaskRows').addEventListener('click', (event) => { + const row = event.target.closest('[data-task-id]'); + if (row) selectDaemonTask(row.dataset.taskId); + }); + $('daemonTaskCancelBtn').addEventListener('click', async () => { + if (!app.selectedDaemonTaskId || !requireToken('任务取消')) return; + try { + await postControl('task-cancel', { task_id: app.selectedDaemonTaskId }); + toast('task.cancel accepted'); + await selectDaemonTask(app.selectedDaemonTaskId); + } catch (error) { + toast(error.message, 'bad'); + } + }); + + $('doctorRefreshBtn').addEventListener('click', () => Promise.allSettled([loadDoctor(), loadDaemonLogs()])); + $('logTailInput').addEventListener('change', loadDaemonLogs); + $('translationRefreshBtn').addEventListener('click', () => Promise.allSettled([loadTranslationTasks(), loadHandoff(), loadTranslationStatus()])); + ['translationStatusFilter', 'translationDestinationFilter', 'translationTaskFilter'].forEach((id) => $(id).addEventListener('input', loadTranslationTasks)); + $('translationTaskRows').addEventListener('click', (event) => { + const row = event.target.closest('[data-translation-task-id]'); + if (!row) return; + const entry = app.translation.tasks.find((item) => taskID(item) === row.dataset.translationTaskId); + if (entry) selectTranslationTask(entry); + }); + $('workerRunBtn').addEventListener('click', runTranslationWorker); + $('manualSaveBtn').addEventListener('click', saveManualResults); + $('proofreadBtn').addEventListener('click', async () => { + if (!requireToken('人工校对状态')) return; + try { + await postControl('translation-proofread', {}); + toast('translation.proofread accepted'); + await loadTranslationStatus(); + } catch (error) { + toast(error.message, 'bad'); + } + }); + $('localizedPublishBtn').addEventListener('click', publishLocalized); + $('localizedRollbackBtn').addEventListener('click', rollbackLocalized); + + $('saveSettingsBtn').addEventListener('click', () => { + app.baseUrl = $('baseUrlInput').value.trim() || defaultBaseUrl(); + app.token = $('tokenInput').value.trim(); + app.rememberToken = $('rememberTokenInput').checked; + localStorage.setItem('bat-api-base-url', app.baseUrl); + localStorage.setItem('bat-api-remember-token', String(app.rememberToken)); + if (app.rememberToken && app.token) localStorage.setItem('bat-api-token', app.token); + if (!app.rememberToken) localStorage.removeItem('bat-api-token'); + refreshAll(); + }); + $('forgetSettingsBtn').addEventListener('click', () => { + localStorage.removeItem('bat-api-base-url'); + localStorage.removeItem('bat-api-token'); + localStorage.removeItem('bat-api-remember-token'); + app.baseUrl = defaultBaseUrl(); + app.token = ''; + app.rememberToken = false; + syncSettingsForm(); + refreshAll(); + }); +} + +function syncSettingsForm() { + $('baseUrlInput').value = app.baseUrl; + $('tokenInput').value = app.token; + $('rememberTokenInput').checked = app.rememberToken; +} + +function configureAutoRefresh() { + if (app.refreshTimer) window.clearInterval(app.refreshTimer); + app.refreshTimer = null; + if ($('autoRefreshInput').checked) { + app.refreshTimer = window.setInterval(refreshAll, 15000); + } +} + +syncSettingsForm(); +bindEvents(); +configureAutoRefresh(); +refreshAll(); diff --git a/web/assets.go b/web/assets.go new file mode 100644 index 0000000..6ea42fc --- /dev/null +++ b/web/assets.go @@ -0,0 +1,8 @@ +package web + +import "embed" + +// Assets 是 bat-api dashboard 的无构建静态资源。 +// +//go:embed index.html app.js styles.css +var Assets embed.FS diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..78952d7 --- /dev/null +++ b/web/index.html @@ -0,0 +1,421 @@ + + + + + + bat-api Dashboard - BlueArchiveToolkit + + + +
+ + +
+
+
+

总览

+

读取 bat-api 与 Rust daemon 的当前状态。

+
+
+ + +
+
+ +
+
+
+
bat-api
+
unknown
+
-
+
+
+
Rust RPC
+
unknown
+
-
+
+
+
资源完整度
+
-
+
-
+
+
+
汉化状态
+
unknown
+
需要 admin token
+
+
+ +
+
+
+
+

当前 release

+

尚未读取

+
+
+
+
+ +
+
+
+

最近任务

+

需要 admin token

+
+ +
+
+
+
+ +
+
+
+

资源样本

+

读取 /v1/resources

+
+ +
+
+ + + + + + + + + + +
路径大小状态BLAKE3
+
+
+
+ +
+
+
+
+

资源清单

+

-

+
+
+ + + +
+
+
+ + +
+
+ + + + + + + + + + +
路径大小状态BLAKE3
+
+
+ - +
+ + +
+
+
+
+ +
+
+
+
+
+

计划列表

+

需要 admin token

+
+ +
+
+ + + +
+
+
+ +
+
+
+

新增或更新

+

schedule.add / schedule.update

+
+
+
+ + + + +
+ + + + +
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
+

翻译任务

+

需要 admin token

+
+ +
+
+ + + +
+
+ + + + + + + + + + +
任务资源状态结果
+
+
+ +
+
+
+

Provider worker

+

translation.worker.run

+
+
+
+
+ + + + + + +
+ + + +
+
+
+ +
+
+
+

人工校对

+

选择一个翻译任务

+
+
+ + +
+
+
+ +
+
+
+ +
+
+
+
+

汉化发布

+

需要 admin token

+
+
+
+ + +
+ + +
+
+
+ +
+
+
+

交接视图

+

translation.handoff

+
+
+
+
+
+
+ +
+
+
+
+

任务列表

+

需要 admin token

+
+ +
+
+ + + + + + + + + + + +
Task IDKindStatusStageUpdated
+
+
+ +
+
+
+

任务详情

+

选择一个任务

+
+ +
+
+

+        
+
+ +
+
+
+
+
+

诊断

+

需要 admin token

+
+ +
+
+
+ +
+
+
+

Daemon log

+

需要 admin token

+
+ +
+

+          
+
+
+ +
+
+
+
+

连接

+

默认使用当前页面同源 bat-api。

+
+
+
+ + + +
+ + +
+
+
+
+
+
+ +
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..d50f015 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,670 @@ +:root { + color-scheme: light; + --bg: #f6f7fb; + --surface: #ffffff; + --surface-muted: #f0f3f8; + --border: #d9e0ea; + --border-strong: #bcc7d5; + --text: #172033; + --muted: #657286; + --blue: #2563eb; + --blue-dark: #1d4ed8; + --green: #15803d; + --amber: #b45309; + --red: #b91c1c; + --shadow: 0 10px 30px rgba(23, 32, 51, 0.08); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + line-height: 1.45; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; +} + +code, +pre { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.shell { + display: grid; + grid-template-columns: 238px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 20px 16px; + background: #111827; + color: #eef2ff; + display: flex; + flex-direction: column; + gap: 22px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 38px; + height: 38px; + border-radius: 8px; + background: #2563eb; + color: #ffffff; + display: grid; + place-items: center; + font-weight: 800; + font-size: 13px; +} + +.brand-title { + font-weight: 700; + font-size: 15px; +} + +.brand-subtitle, +.connection-subtitle, +.muted { + color: var(--muted); +} + +.brand-subtitle, +.connection-subtitle { + color: #94a3b8; + font-size: 12px; +} + +.nav { + display: grid; + gap: 5px; +} + +.nav-item { + width: 100%; + padding: 9px 10px; + border-radius: 6px; + background: transparent; + color: #cbd5e1; + text-align: left; + cursor: pointer; +} + +.nav-item:hover, +.nav-item.is-active { + background: rgba(255, 255, 255, 0.09); + color: #ffffff; +} + +.connection-box { + margin-top: auto; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + display: flex; + gap: 10px; + align-items: flex-start; +} + +.connection-title { + font-weight: 700; +} + +.state-dot { + width: 10px; + height: 10px; + margin-top: 5px; + border-radius: 50%; + background: #94a3b8; + flex: 0 0 auto; +} + +.state-ok { + background: #22c55e; +} + +.state-warn, +.state-loading { + background: #f59e0b; +} + +.state-bad { + background: #ef4444; +} + +.main { + min-width: 0; + padding: 24px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + margin-bottom: 22px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 24px; + font-weight: 750; +} + +h2 { + font-size: 15px; + font-weight: 730; +} + +.topbar p, +.panel-head p, +.metric-note, +.muted { + color: var(--muted); + font-size: 12px; +} + +.topbar-actions, +.row-actions, +.filter-row, +.pager { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.view { + display: none; +} + +.view.is-active { + display: grid; + gap: 16px; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.metric, +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.metric { + padding: 14px; +} + +.metric-label { + color: var(--muted); + font-size: 12px; +} + +.metric-value { + margin-top: 6px; + font-size: 22px; + font-weight: 760; + overflow-wrap: anywhere; +} + +.panel { + overflow: hidden; +} + +.panel-head { + min-height: 58px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-head.split { + align-items: flex-start; +} + +.grid { + display: grid; + gap: 16px; +} + +.two-col { + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.85fr); +} + +.btn { + min-height: 34px; + padding: 7px 12px; + border-radius: 6px; + border: 1px solid var(--border-strong); + background: #ffffff; + color: var(--text); + cursor: pointer; + white-space: nowrap; +} + +.btn:hover { + border-color: var(--blue); + color: var(--blue-dark); +} + +.btn-primary { + border-color: var(--blue); + background: var(--blue); + color: #ffffff; +} + +.btn-primary:hover { + background: var(--blue-dark); + color: #ffffff; +} + +.btn-danger { + border-color: #fecaca; + color: var(--red); +} + +.btn-small { + min-height: 28px; + padding: 5px 9px; + font-size: 12px; +} + +.inline-check, +.inline-label { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 12px; +} + +.inline-label input { + width: 80px; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + background: #ffffff; + color: var(--text); + padding: 8px 10px; + outline: none; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); +} + +textarea { + resize: vertical; +} + +.filter-row { + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.filter-row input, +.filter-row select { + max-width: 260px; +} + +.form { + display: grid; + gap: 11px; + padding: 14px 16px; +} + +.form label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.manual-meta { + border-bottom: 1px solid var(--border); +} + +.manual-meta label { + max-width: 420px; +} + +.table-wrap { + width: 100%; + overflow: auto; +} + +.table-wrap.compact { + max-height: 560px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +th { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + font-weight: 700; +} + +td code { + overflow-wrap: anywhere; +} + +tr.clickable { + cursor: pointer; +} + +tr.clickable:hover { + background: var(--surface-muted); +} + +.empty, +.empty-block { + color: var(--muted); + text-align: center; +} + +.empty-block { + padding: 18px; +} + +.pager { + justify-content: space-between; + padding: 12px 16px; +} + +.list { + display: grid; +} + +.list-item { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.list-item:last-child { + border-bottom: 0; +} + +.compact-item { + padding-block: 10px; +} + +.pill { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-muted); + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.pill-ok { + border-color: #bbf7d0; + background: #f0fdf4; + color: var(--green); +} + +.pill-warn { + border-color: #fde68a; + background: #fffbeb; + color: var(--amber); +} + +.pill-bad { + border-color: #fecaca; + background: #fef2f2; + color: var(--red); +} + +.pill-neutral { + border-color: var(--border); + background: var(--surface-muted); + color: var(--muted); +} + +.kv-list { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 0; + padding: 6px 16px 14px; +} + +.kv-list dt, +.kv-list dd { + margin: 0; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} + +.kv-list dt { + color: var(--muted); + font-size: 12px; +} + +.kv-list dd { + overflow-wrap: anywhere; +} + +.log-box { + min-height: 280px; + margin: 0; + padding: 14px; + background: #0f172a; + color: #dbeafe; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.unit-editor { + display: grid; + gap: 12px; + padding: 14px 16px 16px; +} + +.unit-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + background: #fbfcfe; + display: grid; + gap: 10px; +} + +.unit-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.unit-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.unit-meta span { + padding: 3px 7px; + border-radius: 6px; + background: var(--surface-muted); + color: var(--muted); + font-size: 12px; +} + +.source-label textarea { + background: #f8fafc; + color: #334155; +} + +.settings-panel { + max-width: 720px; +} + +.toast-region { + position: fixed; + right: 18px; + bottom: 18px; + display: grid; + gap: 8px; + z-index: 30; +} + +.toast { + max-width: min(420px, calc(100vw - 36px)); + padding: 10px 12px; + border-radius: 8px; + box-shadow: var(--shadow); + background: #ecfdf5; + color: var(--green); + border: 1px solid #bbf7d0; +} + +.toast-warn { + background: #fffbeb; + color: var(--amber); + border-color: #fde68a; +} + +.toast-bad { + background: #fef2f2; + color: var(--red); + border-color: #fecaca; +} + +@media (max-width: 1080px) { + .shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: relative; + height: auto; + } + + .nav { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .two-col, + .status-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 720px) { + .main { + padding: 16px; + } + + .topbar { + flex-direction: column; + } + + .topbar-actions, + .row-actions, + .filter-row { + width: 100%; + } + + .btn, + .filter-row input, + .filter-row select { + max-width: none; + } + + .status-grid, + .two-col, + .form-grid, + .nav { + grid-template-columns: 1fr; + } + + .kv-list { + grid-template-columns: 1fr; + } + + .kv-list dt { + border-bottom: 0; + padding-bottom: 2px; + } + + .kv-list dd { + padding-top: 0; + } +}