Compare commits
13 Commits
v0.1.0
...
3e1db5e30d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e1db5e30d | |||
| af997b7c06 | |||
| 45a2b361f2 | |||
| 3c8a9ac47c | |||
| 4cd99b8bb6 | |||
| 875a66b0a4 | |||
| e81af2666d | |||
| c03f8ab3d5 | |||
| 9e3d74443e | |||
| cad4e1206a | |||
| 21a6b61b55 | |||
| 05ebc812df | |||
| bf76f13430 |
@@ -97,6 +97,7 @@ SDK 独立发布门禁使用 `make sdk-check`,在 `GOWORK=off` 下检查完整
|
||||
- [HTTP / WebSocket 接口手册](docs/api/README.md)
|
||||
- [Go SDK 接入](docs/sdk/README.md)
|
||||
- [通用场景示例](docs/examples/README.md)
|
||||
- [Orbit Console 控制台](docs/console.md)
|
||||
- [运行与发布手册](docs/20-operations-runbook.md)
|
||||
- [容器发布说明](docs/21-container-deployment.md)
|
||||
- [设计文档总览](docs/README.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Orbit 持续开发与回归计划
|
||||
|
||||
更新:2026-09-20。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。
|
||||
更新:2026-09-21。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。
|
||||
|
||||
## 1. 最终交付目标
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
仓库内的开发回归统一入口为 `make release-local-check`,它只负责可重复的本地门禁;真实 MySQL 全量回归使用 `make integration`,目标拓扑生产验收仍按交付清单单独留证。
|
||||
|
||||
## 1.1 当前交付快照(2026-09-20)
|
||||
## 1.1 当前交付快照(2026-09-21)
|
||||
|
||||
当前不是“还剩一半代码”的状态,而是核心平台已经进入生产验收阶段。为避免把代码完成度和上线完成度混在一起,按三层记录:
|
||||
|
||||
@@ -32,7 +32,19 @@
|
||||
|
||||
### 首个远端接入版本
|
||||
|
||||
本批按用户要求发布仓库 `main` 及 `v0.1.0` 接入版本,独立 SDK 使用标签 `sdk/go/v0.1.0`,供业务项目直接 `go get`。同步移除手册中“尚未发布、必须本地替换”的接入说明,发布后在仓库外使用空模块缓存验证匿名下载、固定版本、无 `replace` 构建与校验和,记录实际远端验证结果。该交付不关闭上方目标环境及完整跨版本生产验收项。
|
||||
已按用户要求推送仓库 `main` 及 `v0.1.0` 接入版本,独立 SDK 标签为 `sdk/go/v0.1.0`,供业务项目直接 `go get`。两个标签固定提交 `0e8b2ef2975df6b55f2240dd14eda83adf30ea94`;仓库外空模块缓存下载、固定版本、无 `replace` 编译、HTTP smoke 和校验和验证已通过,见[远端发布报告](reports/phase-7-sdk-remote-release.md)。本机验证沿用系统网络代理及公开 Go 模块代理,保持 sumdb 校验。该交付不关闭上方目标环境及完整跨版本生产验收项。
|
||||
|
||||
### 控制台第一版
|
||||
|
||||
已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。
|
||||
|
||||
控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。流程实例详情已接入当前视图、步骤状态和有界事件时间线,并支持带 `control_revision` 的暂停、恢复和取消;对 `NEEDS_REVIEW` 实例进一步提供六类带证据的恢复入口(任务核查、步骤结论、继续、步骤重试、PENDING 接续、等待重建),页面只组装协议请求,当前修订、激活身份、权限、审计和状态门控由服务端负责。结果订阅详情已接入配置、积压窗口和交付历史,并支持启停及 EXPIRED 交付追加预算重试。权限不足时保留明确提示,不把页面操作视为绕过服务端事务边界的快捷入口。
|
||||
|
||||
定时计划详情已接入当前版本、规则、下一次触发和 occurrence 历史;暂停/恢复沿用 `schedules:manage`、`expected_revision`、原因和幂等键。计划发布、跨命名空间管理身份和更高风险的交付控制仍按独立权限模型推进。
|
||||
|
||||
控制台五个核心模块已拆为独立 hash 视图,导航状态和页面标题随视图同步,刷新或直接打开对应 hash 后仍保持正确模块。数据请求继续由统一 Bearer Token 和当前队列范围驱动。
|
||||
|
||||
已完成一次真实链路回归:标准 `scripts/test-mysql.sh` 在一次性 MySQL 和真实 Orbit 进程中跑通任务、Worker、结果订阅、Weave 流程、进程恢复及独立业务示例;控制台五个入口、健康检查和详情所依赖的读取 API 已用真实结果核对,证据见 [Console 真实链路回归](reports/phase-7-console-e2e.md)。这关闭了本地闭环验证项,生产拓扑和发布门槛仍保持未验收。
|
||||
|
||||
## 2. 持续推进方式
|
||||
|
||||
@@ -56,8 +68,8 @@
|
||||
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 已完成平台功能阶段验收:[原范围逐项核对](reports/phase-3.md)、[本轮完整回归](reports/phase-3-delay-recovery.md)。补齐相对延迟、改参数关联示例和四种重试全停恢复场景;20个可运行示例全部通过。全平台生产门槛及SDK首版交付剩余项仍按P6–P7推进 |
|
||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 已完成平台功能阶段验收:[原功能及W01–W17逐项核对](reports/phase-4-5.md)、[版本/暂停/全停回归](reports/phase-4-5-races.md)、[提交故障边界](reports/phase-6-fault-boundaries.md)。研发发布、固定版本、顺序/分支/有限并行、信号/时间等待、公共API/SDK及可运行示例均已交付;生产拓扑与发布兼容继续P6/P7 |
|
||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 已完成平台功能阶段验收:[原功能核对](reports/phase-4-5.md)。有界查询、独立预算、暂停继续/取消、六类恢复、历史/筛选/事件/来源与SDK场景均已交付;真实竞争、COMMIT断线及进程全停保留原身份、截止和预算。运维、容量与首版发布继续P6/P7 |
|
||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 进行中;已补[精确故障边界](reports/phase-6-fault-boundaries.md)和[进程观测](reports/phase-6-observability.md);继续业务积压/阶段延迟、凭据轮换及其余生产门槛 |
|
||||
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 进行中;已补[通用文件转换示例](reports/phase-7-file-conversion.md)、[SDK独立发布门禁](reports/phase-7-sdk-release.md)、[当前协议兼容矩阵](reports/phase-7-compatibility.md)、[历史任务 API 双向演练](reports/phase-7-historical-compatibility.md)、[当前回归状态](reports/phase-7-regression-status.md)、[首版交付核对清单](release-checklist.md)、[发布物身份清单](api/operations.md#发布物身份清单)和[容器发布说明](21-container-deployment.md),镜像已完成 linux/amd64 构建验证,节点版本身份可由 `/version` 核对;`verify --manifest` 已可在数据库核验前绑定候选二进制身份;仍需带标签的完整跨版本矩阵和目标拓扑验收 |
|
||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 进行中;已补[精确故障边界](reports/phase-6-fault-boundaries.md)、[进程观测](reports/phase-6-observability.md)、数据库短暂不可用、备份恢复、GTID 手工提升和定时积压样本;继续业务积压/阶段延迟、凭据轮换及其余生产门槛 |
|
||||
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 进行中;已补[通用文件转换示例](reports/phase-7-file-conversion.md)、[SDK独立发布门禁](reports/phase-7-sdk-release.md)、[当前协议兼容矩阵](reports/phase-7-compatibility.md)、[历史任务 API 双向演练](reports/phase-7-historical-compatibility.md)、[当前回归状态](reports/phase-7-regression-status.md)、[四项闭环验收](reports/phase-7-acceptance-20260921.md)、[首版交付核对清单](release-checklist.md)、[发布物身份清单](api/operations.md#发布物身份清单)和[容器发布说明](21-container-deployment.md),镜像已完成 linux/amd64 构建验证,节点版本身份可由 `/version` 核对;`verify --manifest` 已可在数据库核验前绑定候选二进制身份;仍需带标签的完整跨版本矩阵和目标拓扑验收 |
|
||||
|
||||
SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ docker run --rm \
|
||||
docker run --rm \
|
||||
--env ORBIT_MYSQL_DSN \
|
||||
--env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
|
||||
--env ORBIT_CONSOLE_QUEUE_ID=queue_xxx \
|
||||
--env ORBIT_RUNTIME_BATCH_LIMIT=100 \
|
||||
--env ORBIT_WORKFLOW_TICK_INTERVAL=1s \
|
||||
--env ORBIT_SCHEDULE_TICK_INTERVAL=1s \
|
||||
@@ -79,6 +80,8 @@ docker run --rm \
|
||||
registry.example/orbit:dev
|
||||
```
|
||||
|
||||
`ORBIT_CONSOLE_QUEUE_ID` 是控制台的可选默认队列范围,只用于打开 `/console/` 时预填队列 ID,不是认证凭据。服务端仍要求业务请求携带有权限的 Bearer Token;多队列部署可以省略此变量,改为在浏览器选择凭据 JSON,由其中的 `queue_id` 自动填充。
|
||||
|
||||
生产编排应先完成迁移和 `verify`,再启动新节点。服务收到 `SIGTERM` 后停止接收新连接并在有限窗口内关闭 HTTP、WebSocket 和后台循环;编排器的终止宽限期必须覆盖该窗口。启动后才能把节点加入流量池。
|
||||
|
||||
## 3. 探针和监听边界
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| [08 持续开发计划](08-development-plan.md) | 开发、测试回归、阶段验收与最终交付目标 |
|
||||
| [19 容量模型](19-capacity-model.md) | 企业规模到任务、流程、数据库与连接负载的推导及生产验收方法 |
|
||||
| [P7 协议兼容矩阵](reports/phase-7-compatibility.md) | 当前协议门禁、历史提交双向任务 API 演练与正式标签矩阵边界 |
|
||||
| [P7 Console 真实链路回归](reports/phase-7-console-e2e.md) | 真实 MySQL、Worker、流程、结果订阅与控制台读取闭环 |
|
||||
| [20 运行与发布手册](20-operations-runbook.md) | 启动、发布、滚动停止、数据库恢复、备份和事故处理 |
|
||||
| [21 容器发布说明](21-container-deployment.md) | 最小镜像、迁移/核验/启动顺序、探针和发布边界 |
|
||||
| [09 可靠结果订阅](09-result-subscription-core.md) | 提交水位、冻结结果与可靠交付 |
|
||||
@@ -30,6 +31,7 @@
|
||||
| [流程状态时间线](api/workflow-events.md) | 原始流程/步骤/信号状态事件、分页和历史详情 |
|
||||
| [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 |
|
||||
| [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 |
|
||||
| [Orbit Console](console.md) | 内置控制台、本地打开方式与权限边界 |
|
||||
|
||||
## 已确认的决策
|
||||
|
||||
|
||||
49
docs/console.md
Normal file
49
docs/console.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Orbit Console · 星轨控制台
|
||||
|
||||
Orbit 内置了一个不依赖 Node.js、外部 CDN 或额外文件挂载的管理控制台第一版。静态资源编译进 Orbit 二进制,入口为:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080/console/
|
||||
```
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
|
||||
- 通过用户选择的本地凭据 JSON 或手工 Token/队列范围调用现有授权 API;队列范围优先从凭据 JSON 或服务端默认配置自动填充。
|
||||
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;连接参数收进“连接设置”抽屉,不占用统计区域。
|
||||
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
|
||||
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
||||
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
||||
- 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。
|
||||
- 点击流程实例查看当前权威状态、固定版本、步骤状态和事件时间线;步骤读取沿用 `workflows:read`,事件时间线没有 `workflows:audit:read` 时会明确显示权限提示。
|
||||
- 点击结果订阅查看冻结配置、游标、积压窗口和最近交付历史;页面不展示租约秘密,也不绕过 `events:read` / `events:manage` 权限。
|
||||
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
|
||||
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
||||
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
|
||||
- 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。
|
||||
- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
|
||||
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
|
||||
|
||||
## 本地打开
|
||||
|
||||
启动 Orbit 后直接访问 `/console/`。当前 Docker 开发环境可用:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080/console/
|
||||
```
|
||||
|
||||
启动 Orbit 后,首页先展示运行统计;需要切换环境或加载凭据时,点击顶部齿轮、首页“连接设置”或统计旁的设置入口。API 地址默认使用当前控制台页面所在的地址,不需要手工填写。点击“选择凭据 JSON”,选择 `orbit-admin bootstrap` 生成的私有凭据文件,例如 `.local/local.credentials.json`,队列范围和 Token 会自动读取,再点击“连接”。浏览器不能在没有用户选择的情况下读取本机文件,这是浏览器的安全边界;文件只在当前页面内解析,Token 不会上传到 Orbit Console,也不写入 Orbit 数据库。
|
||||
|
||||
如果一个部署只服务一个默认队列,可以在服务端配置队列范围,页面打开后会自动使用它:
|
||||
|
||||
```sh
|
||||
export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
||||
```
|
||||
|
||||
`GET /console/config` 只返回这个非敏感的默认队列 ID,不返回 Token、不读取数据库。凭据 JSON 中的 `queue_id` 优先级更高,可以覆盖默认值。没有队列配置时仍可查看健康和版本;读取任务、计划、流程及订阅需要同时提供 Bearer Token 和队列范围。平台不会根据 Token 猜测队列,因为一个 Token 可能被授予多个队列,自动猜测会造成数据范围错误。
|
||||
|
||||
## 安全边界
|
||||
|
||||
控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。
|
||||
|
||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
||||
@@ -6,14 +6,15 @@
|
||||
|
||||
| 项目 | 证据/命令 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 服务端与 SDK 代码来自同一提交 | `git rev-parse HEAD`;发布时记录不可变提交 | 待发布候选冻结 |
|
||||
| 服务端与 SDK 代码来自同一提交 | 解引用 `v0.1.0` 与 `sdk/go/v0.1.0` | 已固定同一提交,空缓存外部项目取包/编译/校验和通过,见[远端发布报告](reports/phase-7-sdk-remote-release.md);生产发布审批仍待完成 |
|
||||
| Go 工具链 | Go 1.27.1;Docker `golang:1.27.1`,真实 `go.mod`/`GOWORK=off` | 模块级 race/vet/build、MySQL 存储/进程组、24 个 SDK 示例及两个独立业务模块真实集成已通过;发布环境仍需复核 |
|
||||
| 容器发布物 | `make image`;[容器发布说明](21-container-deployment.md) | native linux/arm64 与显式 linux/amd64 镜像构建已通过,支持注入版本身份并由 `/version` 核对;生产镜像标签、摘要、签名和目标编排环境仍需冻结 |
|
||||
| 发布物身份清单 | `orbit-admin release manifest`、`orbit-admin verify --manifest`;[运维接口](api/operations.md) | 已实现无需数据库的机器可读清单及部署前二进制绑定校验,包含构建身份、协议版本和全部迁移校验和;正式发布仍需把清单、镜像摘要与目标库 `verify` 输出一并冻结 |
|
||||
| 服务端单元、竞态、vet、build | `make test && make vet && make build` | Go 1.27.1 Docker 等价命令已通过;宿主机 `make` 因未安装 Go 不能作为本机证据 |
|
||||
| 服务端单元、竞态、vet、build | `make test && make vet && make build` | Go 1.27.1 Docker 及宿主机本地门禁已通过,见[当前回归状态](reports/phase-7-regression-status.md) |
|
||||
| 当前协议线一致 | `make protocol-check` | 已通过:HTTP `/v1`、Worker/Event v1、流程 schema 1 |
|
||||
| SDK 独立性和发布门禁 | `make sdk-check` | Go 1.27.1 Docker 下独立 module、示例和业务模块 race/vet/build 已通过 |
|
||||
| 文档与示例 | 本清单、[接口手册](api/README.md)、[SDK手册](sdk/README.md)、[场景示例](examples/README.md) | 文档链接和 JSON 块已检查;宿主机全量端到端见[当前回归状态](reports/phase-7-regression-status.md) |
|
||||
| 四项闭环验收 | [P7 四项验收记录](reports/phase-7-acceptance-20260921.md) | 当前 Docker/MySQL 环境已通过流程恢复、指标告警、故障容量和 SDK 接入闭环;目标生产拓扑仍需单独验收 |
|
||||
|
||||
## 数据库与安全
|
||||
|
||||
@@ -34,14 +35,14 @@
|
||||
| 派发未送达、提交前后断线、最终信号竞争 | [故障边界报告](reports/phase-6-fault-boundaries.md)、[当前回归状态](reports/phase-7-regression-status.md) | Go 1.27.1 已通过 MySQL 存储、关键 SDK/Orbit 进程故障组、24 个示例串跑和两个独立业务模块集成;跨版本矩阵和目标拓扑仍待发布环境 |
|
||||
| 备份恢复 | `scripts/test-backup-restore.sh`、[恢复报告](reports/phase-6-backup-restore.md) | Go 1.27.1 构建的当前二进制已通过隔离恢复;生产介质待验收 |
|
||||
| MySQL 短暂不可用恢复 | `scripts/test-db-recovery.sh`、[恢复报告](reports/phase-6-db-recovery.md) | Go 1.27.1 构建的当前二进制已通过单节点同卷演练;主从/跨故障域待验收 |
|
||||
| 运行观测和只读快照 | `/metrics`、`orbit-admin observe snapshot`、[观测手册](api/observability.md) | 固定基数和有界单节点快照已实现 |
|
||||
| 运行观测和只读快照 | `/metrics`、`orbit-admin observe snapshot`、[观测手册](api/observability.md) | 固定基数、有界单节点快照、8 条告警规则及离线规则测试已通过;生产采集网络和通知仍待目标环境 |
|
||||
| 告警、值守、回滚/前向修复 | 目标监控、值班和发布工单 | 待目标环境 |
|
||||
|
||||
## 容量与生产高可用
|
||||
|
||||
| 项目 | 必须提供的证据 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 受理、派发、流程推进和恢复吞吐 | 固定硬件、数据量、任务/流程混合模型,p50/p95/p99和资源曲线 | 已有受理样本、[真实Worker短/混合/中长执行样本](reports/phase-6-execution-load.md)、[Weave双分支流程样本](reports/phase-6-workflow-load.md)及[定时集中触发/积压曲线样本](reports/phase-6-backlog.md);目标硬件资源曲线、生产容量和HA仍未验收 |
|
||||
| 受理、派发、流程推进和恢复吞吐 | 固定硬件、数据量、任务/流程混合模型,p50/p95/p99和资源曲线 | 已有受理样本、[真实Worker短/混合/中长执行样本](reports/phase-6-execution-load.md)、[Weave双分支流程样本](reports/phase-6-workflow-load.md)、[定时集中触发/积压曲线样本](reports/phase-6-backlog.md)及本轮[四项闭环验收](reports/phase-7-acceptance-20260921.md);目标硬件资源曲线、生产容量和HA仍未验收 |
|
||||
| 100万+企业规模推导 | [容量模型](19-capacity-model.md):租户、队列、连接、事件、索引和保存周期的参数化推导 | 已建立计算方法和演算样例;目标业务数据、硬件、拓扑和生产容量仍未验收,不能由企业数直接承诺 |
|
||||
| MySQL 复制、切换、RPO/RTO | [隔离 GTID 复制与手工提升演练](reports/phase-6-mysql-replication.md);目标拓扑上的实际切换、复制确认、恢复点和恢复时间 | 隔离源库/副本复制、手工提升和原幂等键重放已通过;自动切换、目标拓扑、RPO/RTO仍未验收 |
|
||||
| 慢消费者、重连风暴、磁盘和锁等待 | 受控故障负载及积压清空证据 | SDK 受控连续断线和真实 HTTP/WebSocket 容量1慢消费者已回归(见[重连报告](reports/phase-6-reconnect-storm.md)、[慢消费者报告](reports/phase-6-slow-consumer.md));跨节点风暴、磁盘和锁等待未验收 |
|
||||
|
||||
69
docs/reports/phase-7-acceptance-20260921.md
Normal file
69
docs/reports/phase-7-acceptance-20260921.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# P7 四项闭环验收记录
|
||||
|
||||
日期:2026-09-21。本文把流程恢复、运行观测、故障/容量和 SDK 接入放在同一次当前提交验收中。所有结果来自本地 Docker、一次性 MySQL 8.4 和 Go 1.27.1;它们证明代码链路可复现,不替代目标生产拓扑的容量、复制切换、RPO/RTO 和发布审批。
|
||||
|
||||
## 1. 真实流程恢复闭环
|
||||
|
||||
执行:
|
||||
|
||||
```sh
|
||||
make integration
|
||||
```
|
||||
|
||||
关键结果:
|
||||
|
||||
- `TestIntegrationWorkflowRecovery` 通过 UNKNOWN 执行核查、步骤结论、继续、任务重试、POLL PENDING、SIGNAL/TIMER 等待恢复、旧证据保护、预算和截止时间保护、审计失败回滚及权限/修订冲突。
|
||||
- `TestIntegrationWorkflowRuntimeProcess` 通过控制器全停、两个新进程接管、人工恢复后继续、POLL 同轮恢复和等待代次重建。
|
||||
- `TestIntegrationWorkflowInstancesSDK` 通过公共 HTTP 与独立 SDK 的六类恢复命令、流程控制、信号、审计和真实 Worker 执行。
|
||||
- WebSocket 结果交付、ACK 丢失、慢消费者、自然租约重投和跨节点接管均通过;完整 MySQL 集成退出 0。
|
||||
|
||||
控制台资源也使用同一公共接口:`/console/`、`/console/app.js`、`/console/styles.css` 返回 200,`NEEDS_REVIEW` 恢复入口要求当前修订、证据引用、原因、显式确认和幂等键。
|
||||
|
||||
## 2. 指标与告警
|
||||
|
||||
执行:
|
||||
|
||||
```sh
|
||||
go test ./internal/observability ./cmd/orbit -count=1
|
||||
docker run --rm --entrypoint /bin/promtool \
|
||||
-v "$PWD:/work:ro" prom/prometheus:v3.5.0 \
|
||||
check rules /work/deploy/monitoring/orbit.rules.yml
|
||||
docker run --rm --entrypoint /bin/promtool \
|
||||
-v "$PWD:/work:ro" prom/prometheus:v3.5.0 \
|
||||
test rules /work/deploy/monitoring/orbit.rules.test.yml
|
||||
```
|
||||
|
||||
结果:指标单元与真实进程采样通过;Prometheus 规则加载 8 条成功,触发、启动宽限、健康不报警、错误恢复和截断恢复测试通过。指标保持独立监听、固定基数、无业务 ID 标签;告警只触发调查,不自动改写任务或流程状态。
|
||||
|
||||
## 3. 故障、备份和容量样本
|
||||
|
||||
| 场景 | 命令 | 当前结果 |
|
||||
| --- | --- | --- |
|
||||
| MySQL 短暂不可用 | `scripts/test-db-recovery.sh` | readiness 在故障时 503,恢复后 200,恢复后任务可受理 |
|
||||
| 逻辑备份恢复 | `scripts/test-backup-restore.sh` | 源库/恢复库核心计数一致,schema 28、外键 0、固定观测索引 6 |
|
||||
| GTID 复制与手工提升 | `scripts/test-mysql-replication.sh` | 复制同步、源库停止、副本手工提升和原幂等键重放通过;自动切换仍未宣称完成 |
|
||||
| 集中定时积压 | `ORBIT_BACKLOG_RUN_TIMEOUT=20s scripts/test-schedule-backlog.sh` | 256 个 occurrence 全部持久化,32 个计划均暂停确认,外键 0;保留有界曲线和指标采样,工具的预期 `tool_exit=1` 表示到达负向截止样本 |
|
||||
|
||||
长时积压样本继续使用 180 秒默认窗口;样本只用于比较积压清空、CPU/RSS、MySQL 资源和连接池曲线,不能直接外推生产吞吐或百万企业容量。
|
||||
|
||||
## 4. SDK 与业务接入
|
||||
|
||||
执行:
|
||||
|
||||
```sh
|
||||
make sdk-check
|
||||
make protocol-check
|
||||
make docs-check
|
||||
make release-local-check
|
||||
```
|
||||
|
||||
结果:SDK 独立 module、`GOWORK=off`、竞态测试、vet、全包构建、协议版本检查和 149 个 Markdown 本地链接均通过;公共 SDK 的流程恢复、事件消费、Outbox/Inbox、提交回执丢失和 WebSocket 重连已有真实服务示例。新增[生产接入闭环](../sdk/production-integration.md),明确 Outbox、HTTP 受理、WebSocket 通知、Inbox 去重、错误分类和上线清单。
|
||||
|
||||
## 5. 仍需目标环境验收
|
||||
|
||||
- 生产硬件和混合负载下的 p50/p95/p99、磁盘、锁等待、复制延迟和容量余量。
|
||||
- 自动故障切换、目标拓扑 RPO/RTO、备份介质和恢复工单。
|
||||
- 生产监控通知、值班升级、凭据托管、日志脱敏和发布审批。
|
||||
- 正式标签之间的完整 SDK/服务端双向兼容矩阵。
|
||||
|
||||
这些项目继续保留在[首版交付清单](../release-checklist.md)中,不能用本地回归结果替代。
|
||||
53
docs/reports/phase-7-console-e2e.md
Normal file
53
docs/reports/phase-7-console-e2e.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# P7:Orbit Console 与真实业务链路回归
|
||||
|
||||
日期:2026-09-21。本批不是用页面假数据演示,而是在一次性 MySQL、真实 Orbit 进程和真实 Go SDK/Worker 上完成平台闭环,再核对控制台读取接口和静态资源。数据库继续禁止外键,测试结束后临时容器、凭据和构建目录均由标准脚本清理。
|
||||
|
||||
## 验收范围
|
||||
|
||||
标准命令:
|
||||
|
||||
```sh
|
||||
./scripts/test-mysql.sh
|
||||
```
|
||||
|
||||
本次回归实际覆盖:
|
||||
|
||||
- 迁移、Schema 核验、无外键升级、健康检查和版本读取。
|
||||
- HTTP 任务受理、幂等重报、查询、取消、延迟、批量和队列背压。
|
||||
- Worker WSS 连接、领取、开始、续租、结果确认、重连、租约过期和进程强杀接管。
|
||||
- 结果订阅创建、配置、事件过滤、WSS 交付、ACK、丢 ACK 恢复、租约过期、人工追加预算和重复确认。
|
||||
- Weave 定义发布、固定版本启动、顺序/并行、定时等待、信号、流程事件审计、暂停/继续、取消、人工核查和进程全停恢复。
|
||||
- 独立 Inbox、Outbox 业务示例,以及 SDK 在真实 HTTP/WSS 服务下的调用。
|
||||
- Orbit Console `/console/`、`app.js`、`styles.css` 和五个 hash 视图的 HTTP 资源检查;详情页使用的流程、步骤、事件、订阅和交付 API 均由同一批真实数据回归覆盖。
|
||||
|
||||
## 结果
|
||||
|
||||
标准脚本退出码为 0。存储、协议、SDK、Worker 进程恢复、结果订阅进程恢复、流程全停恢复和两个独立业务模块均通过;日志中未出现失败测试。关键结果包括:
|
||||
|
||||
```text
|
||||
schema_version=28 migration_count=28 foreign_key_count=0 complete=true
|
||||
workflow state=SUCCEEDED result={"text":"HELLO ORBIT"}
|
||||
workflow_timeline_verified=true signal_transition=PENDING->CONSUMED
|
||||
worker_drained=true
|
||||
management SDK exercised create/get/list/control/detail/expired retry
|
||||
business commit verified before ACK; one business effect and two delivery attempts
|
||||
paused task/signal/timer collected by fresh nodes; public SDK resume preserved version and deadlines
|
||||
```
|
||||
|
||||
控制台资源和页面入口已在本机 Docker 服务确认:
|
||||
|
||||
```text
|
||||
/console/#/overview 200
|
||||
/console/#/tasks 200
|
||||
/console/#/schedules 200
|
||||
/console/#/workflows 200
|
||||
/console/#/subscriptions 200
|
||||
/health/live {"status":"alive"}
|
||||
/health/ready {"scope":"database_and_schema","status":"ready"}
|
||||
```
|
||||
|
||||
## 边界
|
||||
|
||||
这次回归证明真实服务链路、权限边界、事务恢复和控制台读取接口可以一起工作,不等于目标生产拓扑已经完成容量、自动故障切换、RPO/RTO、正式密钥托管、告警和值守验收。控制台仍使用调用方 Bearer Token,页面不直接连接 MySQL,也不把 WSS 租约秘密展示给管理页面。
|
||||
|
||||
下一批工作进入生产门槛:目标拓扑容量、慢消费者和重连风暴、MySQL 自动切换、备份恢复、滚动升级、正式凭据和跨版本兼容矩阵。
|
||||
42
docs/reports/phase-7-sdk-remote-release.md
Normal file
42
docs/reports/phase-7-sdk-remote-release.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# P7:首个远端 SDK 接入版本
|
||||
|
||||
验证日期:2026-09-20。仓库为公开的 [k2safe/orbit](https://git.yuntuo.hk/k2safe/orbit),默认分支为 `main`。本批完成源码推送、独立 SDK 版本发布和真实外部项目取包验证。
|
||||
|
||||
## 发布身份
|
||||
|
||||
| 发布物 | Git 标签 | 不可变源码提交 |
|
||||
| --- | --- | --- |
|
||||
| Orbit 服务端 | `v0.1.0` | `0e8b2ef2975df6b55f2240dd14eda83adf30ea94` |
|
||||
| 独立 Go SDK | `sdk/go/v0.1.0` | `0e8b2ef2975df6b55f2240dd14eda83adf30ea94` |
|
||||
|
||||
通过公开 Git 远端读取标签并解引用,确认两个标签指向同一提交。仓库 API 返回 `private=false`、`empty=false`、`default_branch=main`。`main` 后续可以继续更新文档和代码,接入方应固定上述 SDK 版本。
|
||||
|
||||
## 外部项目验证
|
||||
|
||||
环境为 Go 1.27.1、darwin/arm64。在仓库外创建 `example.com/orbit-remote-consumer`,使用空 `GOMODCACHE`、独立 `GOPATH`、`GOWORK=off` 和 `GOENV=off`;未添加任何 `replace`。关闭 Go HTTP 身份认证及全局 Git 配置,SDK 通过公开代理下载。
|
||||
|
||||
本机 Go 进程直连代理发生网络超时。最终使用 `GOPROXY=https://goproxy.cn,direct`,并仅在验证进程中沿用系统已有的 HTTP/HTTPS 网络代理。保留 `GOSUMDB=sum.golang.org`,未设置 `GOPRIVATE` 或 `GONOSUMDB`,未关闭校验和验证,也未修改用户全局 Go/Git 设置。
|
||||
|
||||
| 验证 | 实际结果 |
|
||||
| --- | --- |
|
||||
| `go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.0` | 从空缓存下载 SDK 和 `github.com/coder/websocket v1.8.15`,退出 0 |
|
||||
| `go mod tidy` | 退出 0 |
|
||||
| 外部项目 `go build` | 退出 0 |
|
||||
| 外部项目运行 SDK HTTP smoke | 真实调用本地 `httptest` HTTP 服务的健康接口,输出 `remote_sdk_http_smoke=passed`,退出 0 |
|
||||
| `go mod verify` | `all modules verified`,退出 0 |
|
||||
| `go list -m -json` | `Version=v0.1.0`、`GoVersion=1.27.1`、无 `Replace` |
|
||||
| 本地独立 SDK 门禁 | race、vet、build 通过;30 个包,禁止依赖为 0 |
|
||||
| 协议与文档门禁 | 当前 HTTP/Worker/Event/schema 契约匹配,Markdown 本地链接检查通过 |
|
||||
|
||||
下载得到的模块校验和:
|
||||
|
||||
```text
|
||||
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0 h1:OJp+U8dDNKjiXy4ZhO4MMq68Ek8TkjphUAqsjOOg77c=
|
||||
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0/go.mod h1:9KlMW7Gy+EGaDoEwr3wHHFY47mnAymSfYeQpMS7h4ag=
|
||||
```
|
||||
|
||||
业务接入命令、导入方式和场景见 [SDK 手册](../sdk/README.md#5-外部项目通过远端接入)。若网络无法访问默认模块代理,可单次执行 `GOPROXY=https://goproxy.cn,direct go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.0`;需要网络代理的环境应让 Go 进程使用可用的网络代理,不需要关闭模块校验。
|
||||
|
||||
## 结论与边界
|
||||
|
||||
业务项目可以固定 `v0.1.0` 进行开发和测试接入,无需取得本机源码或配置本地替换。本批验证远端发布与取包,没有重新声明生产环境验收完成;真实 MySQL/进程全量回归沿用[已记录结果](phase-7-regression-status.md),生产容量、数据库自动切换、完整跨标签矩阵和运维条件仍按[交付清单](../release-checklist.md)完成。
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
已实现健康查询、任务单项与[批量提交](../api/batch-tasks.md)/查询/分页/待执行取消、队列创建/查询、[自动 Worker](worker-runtime.md)、[Worker 底层协议 SDK](worker.md) 和 [执行历史查询](../api/execution-history.md)。[人工核查与重试](../api/task-recovery.md) 已提供 ReviewTask、RetryTask 与任务操作审计查询。[安全重试](../api/retries.md) 已提供有限策略、WorkerFailure.Retryable、历史查询及完整例子。[计划调度](../api/schedules.md) 已支持创建、查询、版本发布、暂停/恢复、触发历史与完整示例。[订阅管理](../api/subscriptions.md) 已支持创建、查询、暂停恢复、交付历史与人工重试;[事件消费 SDK](event-consumer.md) 已支持连接、自动心跳、重连与确认。[流程定义发布](../api/workflow-definitions.md)、[实例/控制/信号/操作审计](../api/workflows.md)及[六类人工恢复](../api/workflow-recovery.md)已提供独立SDK;完整历史与更多场景按[开发计划](../08-development-plan.md)继续实现。
|
||||
|
||||
生产接入推荐按[生产接入闭环](production-integration.md)执行:业务 Outbox 持久化幂等身份,SDK 负责 HTTP 受理和查询,Event WebSocket 负责可靠通知,业务 Inbox 负责去重和确认;网络结果不明时始终保留原键核对。
|
||||
|
||||
module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,依赖 Go 标准库与 `github.com/coder/websocket v1.8.15`,不引用服务端模型或数据库实现。
|
||||
|
||||
发布候选先执行 [SDK 独立发布门禁](release.md) 和[协议兼容矩阵](compatibility.md),确认 `GOWORK=off` 下依赖图、竞态测试、vet 和全包构建均通过;门禁不替代业务副作用回归。
|
||||
@@ -167,7 +169,7 @@ go mod verify
|
||||
|
||||
SDK 位于仓库子目录,对应 Git 标签为 `sdk/go/v0.1.0`;`go get` 使用模块版本 `v0.1.0`。服务端对应仓库标签为 `v0.1.0`。业务项目固定版本并提交 `go.mod` 与 `go.sum`,无需本地源码、`replace` 或 Orbit 的 `go.work`。如果此前配置过本地替换,先执行 `go mod edit -dropreplace=git.yuntuo.hk/k2safe/orbit/sdk/go`。
|
||||
|
||||
这是开发与测试接入版本;生产部署仍按[交付清单](../release-checklist.md)完成目标环境验收。版本发布和校验规则见[发布说明](release.md)。
|
||||
这是开发与测试接入版本;生产部署仍按[交付清单](../release-checklist.md)完成目标环境验收。版本发布和校验规则见[发布说明](release.md),空缓存远端取包结果见[验证报告](../reports/phase-7-sdk-remote-release.md)。
|
||||
|
||||
## 6. 查询执行历史与证据
|
||||
|
||||
|
||||
75
docs/sdk/production-integration.md
Normal file
75
docs/sdk/production-integration.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 生产接入闭环
|
||||
|
||||
这份手册把 SDK、HTTP 查询和 WebSocket 结果通知串成一条可落地的接入链路。它只描述平台协议,不包含任何业务系统名称、行业规则或外部副作用。
|
||||
|
||||
## 1. 接入边界
|
||||
|
||||
业务服务保存操作身份和请求正文,再调用 SDK 提交。`Idempotency-Key` 必须由业务库或 Outbox 持久化,网络超时、进程重启和响应不明时复用原键;不能生成新键猜测服务端是否已提交。受理回执只表示平台已经保存请求,不能当作任务或流程完成。
|
||||
|
||||
```go
|
||||
api, err := orbit.NewClient(baseURL, orbit.Options{Token: token})
|
||||
if err != nil { return err }
|
||||
|
||||
// operationKey 与请求正文一起写入业务库;重试始终复用它。
|
||||
receipt, err := api.SubmitTask(ctx, operationKey, orbit.SubmitTaskRequest{
|
||||
QueueID: queueID, Handler: "file.convert", HandlerVersion: "v1",
|
||||
PayloadVersion: "v1", Payload: json.RawMessage(`{"source_ref":"objects/input.csv"}`),
|
||||
RecoverySafety: orbit.RecoveryReconcileOnly,
|
||||
})
|
||||
if err != nil { return err }
|
||||
taskID := receipt.Task.ID
|
||||
```
|
||||
|
||||
凭据只授予调用方所需的命名空间、队列和动作范围。生产使用 HTTPS;SDK 不负责 TLS 终止、密钥托管或外部业务核查。所有 API 错误都保留 `RequestID`、稳定错误码和 HTTP 状态,日志不得写入 Token、完整业务键或结果正文。
|
||||
|
||||
## 2. 完成通知与权威查询
|
||||
|
||||
结果通知使用 Event WebSocket。连接时声明 `orbit.events.v1` 子协议和允许的来源,SDK 自动发送心跳、在连接断开后重连,并把上一次未确认的投递证明带回新连接。处理器完成业务事务后再调用 `Ack`;处理器失败或进程在提交前退出时不要确认,平台会在租约到期后重投同一交付。
|
||||
|
||||
```go
|
||||
consumer := orbit.NewEventConsumer(api, orbit.EventConsumerOptions{
|
||||
SubscriptionID: subscriptionID,
|
||||
MaxHandlers: 8,
|
||||
})
|
||||
err := consumer.Run(ctx, func(ctx context.Context, delivery orbit.EventDelivery) error {
|
||||
// 先在业务 Inbox 中按 delivery.EventID 去重,再提交业务事务。
|
||||
// 返回 nil 后 SDK 才发送 ACK;返回错误会保留交付等待重投。
|
||||
return applyOnce(ctx, delivery)
|
||||
})
|
||||
if err != nil { return err }
|
||||
```
|
||||
|
||||
通知只推动业务侧再次读取权威状态。收到 `task.completed`、`workflow.stopped` 或 `workflow.review_required` 后,调用 `GetTask`、`GetWorkflow` 或对应历史接口;事件快照不替代当前状态,也不表示外部效果已经完成。没有 WebSocket 时可以使用有界 HTTP 查询作为兜底,但必须使用退避、截止时间和业务键,不得无限轮询。
|
||||
|
||||
## 3. 流程实例的恢复顺序
|
||||
|
||||
流程进入 `NEEDS_REVIEW` 后,接入方按下面顺序处理:
|
||||
|
||||
1. 读取当前实例、当前步骤、相关任务和外部证据,保存当时的 `revision`、步骤 `revision` 与 `activation_id`。
|
||||
2. 使用对应恢复方法提交一个有证据的决定:任务核查、步骤结论、步骤重试、PENDING 接续或等待重建。
|
||||
3. 等待内部结果被消费,再重新读取实例和步骤;不要把恢复回执快照当作实时状态。
|
||||
4. 所有核查事项完成后提交 `ContinueWorkflow`,如果实例仍暂停,再单独提交 `Resume`。
|
||||
5. 使用恢复回执的 `audit_id` 查询操作审计,记录操作者、原因、证据引用和前后状态。
|
||||
|
||||
`STATE_CONFLICT`、`UNAVAILABLE` 或响应未知时保留原请求体和原幂等键,先用相同身份核对;不要把版本刷新后重放成另一种决定。服务端会检查流程版本、步骤版本、激活代次、任务归属、未确认结果、预算和截止时间。
|
||||
|
||||
## 4. 错误处理约定
|
||||
|
||||
| 情况 | 接入方动作 |
|
||||
| --- | --- |
|
||||
| HTTP 超时、连接断开、响应未知 | 保留原键和原请求,查询或用原键重报;不生成新键 |
|
||||
| `STATE_CONFLICT` | 重新读取当前事实和审计,确认是否已有新结果;不能盲目覆盖 |
|
||||
| `UNAVAILABLE` / readiness 503 | 保留业务 Outbox,等待服务恢复;不能把故障改写为任务失败 |
|
||||
| `QUEUE_FULL` / `APPLICATION_FULL` | 按业务退避或转人工,保留原身份;释放前不要无限加并发 |
|
||||
| `NEEDS_REVIEW` | 进入证据核查流程;禁止自动重试未知外部效果 |
|
||||
| WebSocket 断线 | 不确认未完成处理;让租约自然重投,并使用交付证明去重 |
|
||||
|
||||
SDK 的 `revision`、`sequence` 和游标使用字符串 JSON 编码或 `uint64`,业务侧不得转成浮点数。结果正文使用 `json.RawMessage` 保留精确数字;大正文传对象引用并在业务侧设置独立保留和访问策略。
|
||||
|
||||
## 5. 上线前清单
|
||||
|
||||
- 固定服务端版本和 `git.yuntuo.hk/k2safe/orbit/sdk/go` 的 SDK 版本,执行 `go mod verify`。
|
||||
- 运行 `make protocol-check`、`make sdk-check` 和对应版本的真实 MySQL 集成回归。
|
||||
- 持久化提交键、恢复键、业务 Outbox 和消费 Inbox;演练提交回执丢失、进程强杀和 WebSocket 重连。
|
||||
- 为 `/health/live`、`/health/ready` 和独立 `/metrics` 配置内部监控;确认告警不会自动改写业务状态。
|
||||
- 验证凭据轮换、权限撤销、备份恢复、MySQL 切换和容量阈值,再进入生产审批。
|
||||
@@ -40,6 +40,6 @@ go mod verify
|
||||
|
||||
仓库主分支为 `main`。服务端标签 `v0.1.0` 和独立 SDK 标签 `sdk/go/v0.1.0` 指向同一发布提交;Go 子模块标签必须包含 `sdk/go/` 前缀,业务端的模块版本不带此前缀。已发布标签保持不可变,修复使用新的补丁版本。`v0.1.0` 用于开发与测试接入,正式生产仍需完成[交付核对清单](../release-checklist.md)。
|
||||
|
||||
发布后需在仓库外新建业务 module、使用空模块缓存和 `GOWORK=off`,通过匿名远端下载,执行 `go mod tidy`、编译、SDK 调用 smoke 和 `go mod verify`,确认解析版本精确为 `v0.1.0` 且不存在 `Replace`。后续发布继续重复这一步,并按[兼容矩阵](compatibility.md)验证旧/新组合。
|
||||
`v0.1.0` 已在仓库外的新业务 module、空模块缓存和 `GOWORK=off` 下通过公开远端下载、`go mod tidy`、编译、SDK HTTP smoke 和 `go mod verify`,解析版本精确为 `v0.1.0` 且不存在 `Replace`。发布提交、校验和与网络条件见[远端验证报告](../reports/phase-7-sdk-remote-release.md)。后续发布继续重复这一步,并按[兼容矩阵](compatibility.md)验证旧/新组合。
|
||||
|
||||
本地联调可以临时添加 `replace`,但不能提交到业务项目的发布分支。SDK 不负责 TLS 终止、凭据托管、对象存储或外部副作用核查;这些边界仍由平台接口手册和接入方运行手册定义。
|
||||
|
||||
50
internal/transport/httpserver/console.go
Normal file
50
internal/transport/httpserver/console.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// consoleAssets 将控制台静态资源编译进 Orbit,避免运行容器依赖 Node.js、外部 CDN
|
||||
// 或额外的文件挂载。控制台只调用版本化 HTTP API,不读取数据库。
|
||||
//
|
||||
//go:embed console
|
||||
var consoleAssets embed.FS
|
||||
|
||||
// RegisterConsole 注册本地及生产可复用的 Orbit 控制台入口。
|
||||
// /console 不改变业务 API 的认证和路由;静态页面自身不包含凭据。
|
||||
func RegisterConsole(server *http.Server) {
|
||||
if server == nil {
|
||||
return
|
||||
}
|
||||
mux, ok := server.Handler.(*http.ServeMux)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assets, err := fs.Sub(consoleAssets, "console")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
files := http.StripPrefix("/console/", http.FileServer(http.FS(assets)))
|
||||
mux.HandleFunc("GET /console", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/console/", http.StatusPermanentRedirect)
|
||||
})
|
||||
mux.HandleFunc("GET /console/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
// 队列 ID 只是控制台的默认范围,不是凭据。Token 仍由每个授权 API
|
||||
// 自己校验;这里不返回任何秘密,也不访问数据库。
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
DefaultQueueID string `json:"default_queue_id"`
|
||||
}{DefaultQueueID: os.Getenv("ORBIT_CONSOLE_QUEUE_ID")})
|
||||
})
|
||||
mux.Handle("GET /console/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
files.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
492
internal/transport/httpserver/console/app.js
Normal file
492
internal/transport/httpserver/console/app.js
Normal file
@@ -0,0 +1,492 @@
|
||||
(() => {
|
||||
const state = { token: '', queue: '', base: location.origin, consoleConfig: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
|
||||
const sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
|
||||
$('api-url').value = location.origin;
|
||||
$('queue-id').value = sessionQueue;
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
|
||||
}
|
||||
function formatTime(value) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
|
||||
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date);
|
||||
}
|
||||
function shortID(id) { return id && id.length > 14 ? `${id.slice(0, 8)}…${id.slice(-4)}` : (id || '—'); }
|
||||
function stateClass(value) { return String(value || '').toLowerCase(); }
|
||||
function extractItems(payload) { return payload?.data?.items || payload?.items || []; }
|
||||
function extractData(payload) { return payload?.data ?? payload ?? {}; }
|
||||
function currentView() {
|
||||
const raw = location.hash.replace(/^#\/?/, '').split('/')[0];
|
||||
return pageNames[raw] ? raw : 'overview';
|
||||
}
|
||||
function setView(view, updateHash = true) {
|
||||
const next = pageNames[view] ? view : 'overview';
|
||||
document.querySelectorAll('.page-view').forEach(page => page.classList.toggle('active', page.dataset.page === next));
|
||||
document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === next));
|
||||
text('page-title', pageNames[next]);
|
||||
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
async function request(path, options = {}) {
|
||||
const headers = { Accept: 'application/json', ...(options.headers || {}) };
|
||||
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
||||
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers });
|
||||
const raw = await response.text(); let body = {};
|
||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
|
||||
return body;
|
||||
}
|
||||
async function loadConsoleConfig() {
|
||||
try {
|
||||
const response = await fetch('/console/config', { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||||
if (!response.ok) return;
|
||||
state.consoleConfig = await response.json();
|
||||
const defaultQueue = String(state.consoleConfig?.default_queue_id || '').trim();
|
||||
if (!state.queue && defaultQueue) {
|
||||
state.queue = defaultQueue;
|
||||
$('queue-id').value = defaultQueue;
|
||||
sessionStorage.setItem('orbit.console.queue', defaultQueue);
|
||||
text('queue-hint', '已使用服务端默认队列配置;也可选择凭据 JSON 覆盖。');
|
||||
renderConnectionSummary();
|
||||
}
|
||||
} catch {
|
||||
// 连接表单仍可使用手工 API 地址和凭据,不因可选配置失败而阻断页面。
|
||||
}
|
||||
}
|
||||
function renderConnectionSummary() {
|
||||
text('summary-api', state.base || location.origin);
|
||||
text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面');
|
||||
text('summary-queue', state.queue ? shortID(state.queue) : '未配置');
|
||||
text('summary-credential', state.token ? '已载入' : '未载入');
|
||||
text('summary-note', state.token && state.queue ? '已具备读取业务数据的连接条件。' : '服务状态会自动检查;需要业务数据时再加载凭据。');
|
||||
}
|
||||
function setConnection(connected, detail = '') {
|
||||
state.connected = connected;
|
||||
const authenticated = connected && Boolean(state.token && state.queue);
|
||||
$('connection-state').textContent = authenticated ? '已连接' : (connected ? '服务在线' : '未连接');
|
||||
$('connection-state').classList.toggle('ready', connected);
|
||||
$('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接');
|
||||
$('side-endpoint').textContent = connected ? state.base : 'API 未连接';
|
||||
$('side-progress').style.width = connected ? '100%' : '0%';
|
||||
document.querySelector('.status-dot').classList.toggle('ready', connected);
|
||||
document.querySelector('.live-chip').classList.toggle('ready', connected);
|
||||
$('live-label').textContent = connected ? '环境在线' : '本地环境';
|
||||
renderConnectionSummary();
|
||||
if (detail) showToast(detail);
|
||||
}
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const [live, version] = await Promise.all([request('/health/live'), request('/version')]);
|
||||
text('metric-health', live?.status === 'alive' ? '在线' : '异常');
|
||||
text('metric-health-sub', `Go ${version?.go_version || '—'} · API v${version?.http_protocol_version || '—'}`);
|
||||
text('side-status', '环境已连接');
|
||||
return version;
|
||||
} catch (error) {
|
||||
text('metric-health', '离线'); text('metric-health-sub', error.message); setConnection(false); throw error;
|
||||
}
|
||||
}
|
||||
function renderTasks(items) {
|
||||
text('task-count', `${items.length} 项`); text('metric-tasks', String(items.length));
|
||||
const counts = items.reduce((acc, item) => { const key = stateClass(item.state); acc[key] = (acc[key] || 0) + 1; return acc; }, {});
|
||||
const total = Math.max(items.length, 1); const width = (name) => `${(counts[name] || 0) / total * 100}%`;
|
||||
$('task-bar-succeeded').style.width = width('succeeded'); $('task-bar-running').style.width = width('running'); $('task-bar-waiting').style.width = `${((counts.waiting || 0) + (counts.pending || 0)) / total * 100}%`; $('task-bar-other').style.width = `${((counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0)) / total * 100}%`;
|
||||
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
|
||||
const body = $('task-table');
|
||||
if (!items.length) { body.innerHTML = '<tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
|
||||
body.innerHTML = items.slice(0, 8).map(item => `<tr class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).join('');
|
||||
body.querySelectorAll('.task-row').forEach(row => {
|
||||
row.addEventListener('click', () => openTaskDetail(row.dataset.taskId));
|
||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } });
|
||||
});
|
||||
}
|
||||
function renderSchedules(items) {
|
||||
text('schedule-page-hint', `${items.length} 项 · 点击计划查看详情`);
|
||||
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>需要 schedules:read 权限</small></div></div>'; return; }
|
||||
el.innerHTML = items.map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).join('');
|
||||
el.querySelectorAll('.schedule-row').forEach(row => {
|
||||
row.addEventListener('click', () => openScheduleDetail(row.dataset.scheduleId));
|
||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScheduleDetail(row.dataset.scheduleId); } });
|
||||
});
|
||||
}
|
||||
function renderWorkflows(items) {
|
||||
text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例');
|
||||
const el = $('workflow-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>需要 workflows:read 权限</small></div></div>'; return; }
|
||||
el.innerHTML = items.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
|
||||
el.querySelectorAll('.workflow-row').forEach(row => {
|
||||
row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId));
|
||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } });
|
||||
});
|
||||
}
|
||||
function renderSubscriptions(items) {
|
||||
text('metric-subscriptions', String(items.length));
|
||||
const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`;
|
||||
text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
|
||||
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>需要 events:read 权限</small></div></div>'; return; }
|
||||
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
|
||||
el.querySelectorAll('.subscription-row').forEach(row => {
|
||||
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
|
||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
|
||||
});
|
||||
}
|
||||
function renderRuntimeStats(tasks, schedules, workflows, subscriptions) {
|
||||
if (Array.isArray(tasks)) {
|
||||
const counts = tasks.reduce((acc, item) => { const key = stateClass(item.state); acc[key] = (acc[key] || 0) + 1; return acc; }, {});
|
||||
const total = tasks.length;
|
||||
text('stat-success-rate', total ? `${Math.round((counts.succeeded || 0) / total * 100)}%` : '—');
|
||||
text('stat-success-rate-sub', total ? `${counts.succeeded || 0} 成功 / ${total} 项` : '当前没有任务');
|
||||
text('stat-task-total', `${total} 项`);
|
||||
const waiting = (counts.waiting || 0) + (counts.pending || 0);
|
||||
const other = (counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0);
|
||||
const width = value => `${total ? value / total * 100 : 0}%`;
|
||||
[['succeeded', counts.succeeded || 0], ['running', counts.running || 0], ['waiting', waiting], ['other', other]].forEach(([name, value]) => { $(`stat-bar-${name}`).style.width = width(value); text(`stat-count-${name}`, String(value)); });
|
||||
} else {
|
||||
text('stat-success-rate', '—'); text('stat-success-rate-sub', '暂无任务权限'); text('stat-task-total', '暂无数据');
|
||||
['succeeded', 'running', 'waiting', 'other'].forEach(name => { $(`stat-bar-${name}`).style.width = '0%'; text(`stat-count-${name}`, '—'); });
|
||||
}
|
||||
if (Array.isArray(schedules)) {
|
||||
const active = schedules.filter(item => item.paused !== true).length;
|
||||
text('stat-active-schedules', String(active)); text('stat-active-schedules-sub', `${schedules.length} 项计划 · ${schedules.length - active} 项暂停`);
|
||||
} else { text('stat-active-schedules', '—'); text('stat-active-schedules-sub', '暂无计划权限'); }
|
||||
if (Array.isArray(workflows)) {
|
||||
const review = workflows.filter(item => stateClass(item.state) === 'needs_review').length;
|
||||
text('stat-review-workflows', String(review)); text('stat-review-workflows-sub', `${workflows.length} 项流程实例`);
|
||||
} else { text('stat-review-workflows', '—'); text('stat-review-workflows-sub', '暂无流程权限'); }
|
||||
if (Array.isArray(subscriptions)) {
|
||||
const pending = subscriptions.reduce((sum, item) => sum + (Number(item.pending_count) || 0), 0);
|
||||
text('stat-pending-deliveries', String(pending)); text('stat-pending-deliveries-sub', `${subscriptions.length} 个订阅 · 待确认结果`);
|
||||
} else { text('stat-pending-deliveries', '—'); text('stat-pending-deliveries-sub', '暂无事件权限'); }
|
||||
text('stats-snapshot', state.connected ? `更新于 ${formatTime(new Date())}` : '等待连接');
|
||||
}
|
||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||
function formatJSON(value) {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2); } catch { return String(value); }
|
||||
}
|
||||
function operationKey(prefix) {
|
||||
const id = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `console-${prefix}-${id}`.slice(0, 128);
|
||||
}
|
||||
function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
|
||||
function openConnectionSettings() { $('connection-settings-backdrop').hidden = false; document.body.classList.add('drawer-open'); $('api-url').focus(); }
|
||||
function closeConnectionSettings() { $('connection-settings-backdrop').hidden = true; document.body.classList.remove('drawer-open'); }
|
||||
function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
||||
function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); }
|
||||
function renderDetailList(id, items, emptyText, renderItem) {
|
||||
const el = $(id);
|
||||
if (!items.length) { el.innerHTML = `<div class="detail-empty">${escapeHTML(emptyText)}</div>`; return; }
|
||||
el.innerHTML = items.map(renderItem).join('');
|
||||
}
|
||||
function renderTaskDetail(task) {
|
||||
state.selectedTask = task;
|
||||
text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', task.state || 'UNKNOWN');
|
||||
$('detail-state').className = `state-pill ${stateClass(task.state)}`;
|
||||
text('detail-updated', `最后更新 ${formatTime(task.updated_at)}`); text('detail-handler', `${task.handler || '—'} · ${task.handler_version || '—'}`);
|
||||
text('detail-revision', task.revision || '—'); text('detail-attempt-count', task.attempt_count ?? '0'); text('detail-effect', task.effect_uncertain ? '待核查' : '已确认');
|
||||
text('detail-created', formatTime(task.created_at)); text('detail-deadline', formatTime(task.deadline_at));
|
||||
const resultBlock = $('detail-result-block'); resultBlock.hidden = !task.result; if (task.result) $('detail-result').textContent = formatJSON(task.result);
|
||||
const canCancel = !terminalState(task.state) && !task.cancel_requested_at;
|
||||
const canRetry = (task.state === 'FAILED' || task.state === 'CANCELLED') && !task.effect_uncertain;
|
||||
$('detail-actions').hidden = !(canCancel || canRetry); $('task-cancel-button').hidden = !canCancel; $('task-retry-button').hidden = !canRetry;
|
||||
text('detail-action-note', task.state === 'NEEDS_REVIEW' ? '此任务需要先完成带证据的人工核查,确认旧执行已经结清后再决定最终状态。' : '写操作需要对应的管理权限;服务端会再次校验版本、状态、幂等键和队列范围。');
|
||||
}
|
||||
function renderAttempts(items, error) {
|
||||
text('detail-attempts-count', error ? '权限不足' : `${items.length} 次`);
|
||||
renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(item.state || 'UNKNOWN')}</b><small>${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${item.retryable ? '可重试' : '不可重试'}</em></div>`);
|
||||
}
|
||||
function renderOperations(items, error) {
|
||||
text('detail-operations-count', error ? '权限不足' : `${items.length} 条`);
|
||||
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.action || '操作')}</b><small>${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${escapeHTML(shortID(item.id))}</em></div>`);
|
||||
}
|
||||
async function openTaskDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 tasks:read 权限的凭据'); return; }
|
||||
showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true;
|
||||
$('detail-attempts').innerHTML = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
|
||||
const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
|
||||
if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); return; }
|
||||
const task = extractData(taskResult.value); renderTaskDetail(task);
|
||||
renderAttempts(attemptsResult.status === 'fulfilled' ? extractItems(attemptsResult.value) : [], attemptsResult.status === 'rejected');
|
||||
renderOperations(operationsResult.status === 'fulfilled' ? extractItems(operationsResult.value) : [], operationsResult.status === 'rejected');
|
||||
}
|
||||
function closeActionForm() { state.actionMode = ''; $('task-action-form').hidden = true; $('action-error').textContent = ''; }
|
||||
function openActionForm(mode) {
|
||||
if (!state.selectedTask) return;
|
||||
state.actionMode = mode; $('task-action-form').hidden = false; $('action-error').textContent = ''; $('action-reason').value = ''; $('action-evidence').value = `evidence:console-${Date.now()}`; $('action-safe').checked = false;
|
||||
const retry = mode === 'retry'; text('action-kicker', retry ? 'TASK RECOVERY' : 'TASK CONTROL'); text('action-title', retry ? '安全重试任务' : '取消任务'); text('action-description', retry ? '只有确认旧执行不会继续产生外部效果时,才能创建新的关联任务。' : '取消会记录持久化取消意图;运行中的处理器仍需上报结果或等待恢复。');
|
||||
$('action-evidence-wrap').hidden = !retry; $('action-timeout-wrap').hidden = !retry; $('action-safe-wrap').hidden = !retry; $('action-submit').textContent = retry ? '创建重试任务 →' : '提交取消 →'; $('action-reason').focus();
|
||||
}
|
||||
async function submitAction(event) {
|
||||
event.preventDefault(); if (!state.selectedTask || !state.actionMode) return;
|
||||
const reason = $('action-reason').value.trim(); if (!reason) { $('action-error').textContent = '请填写操作原因。'; return; }
|
||||
const task = state.selectedTask; const retry = state.actionMode === 'retry'; const body = { expected_revision: String(task.revision), reason };
|
||||
const path = `/v1/tasks/${encodeURIComponent(task.id)}:${retry ? 'retry' : 'cancel'}`;
|
||||
if (retry) {
|
||||
const timeout = Number($('action-timeout').value); if (!$('action-safe').checked) { $('action-error').textContent = '请先确认安全重试声明。'; return; }
|
||||
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2592000000) { $('action-error').textContent = '任务总时限必须是 1 到 2592000000 毫秒。'; return; }
|
||||
const evidence = $('action-evidence').value.trim(); if (!evidence) { $('action-error').textContent = '请填写证据引用。'; return; }
|
||||
Object.assign(body, { evidence_ref: evidence, safe_to_retry: true, task_timeout_ms: timeout });
|
||||
}
|
||||
const button = $('action-submit'); button.disabled = true; $('action-error').textContent = '';
|
||||
try { const response = await request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(retry ? 'retry' : 'cancel') }, body: JSON.stringify(body) }); showToast(retry ? '已创建关联重试任务' : '取消意图已提交'); closeActionForm(); await refresh(); await openTaskDetail(task.id); if (response?.data?.task?.id && retry) showToast(`重试任务:${shortID(response.data.task.id)}`); }
|
||||
catch (error) { $('action-error').textContent = error.message || '操作失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
function showScheduleDrawer() { $('schedule-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
||||
function closeScheduleDrawer() { $('schedule-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeScheduleAction(); }
|
||||
function closeScheduleAction() { $('schedule-action-form').hidden = true; $('schedule-action-error').textContent = ''; }
|
||||
function scheduleRuleLabel(rule) { if (!rule) return '—'; return rule.kind === 'CRON' ? `Cron · ${rule.cron || '—'}` : `固定间隔 · ${rule.interval_ms || '—'} ms`; }
|
||||
function renderScheduleOccurrences(items, error) {
|
||||
text('schedule-occurrences-count', error ? '权限不足' : `${items.length} 条`);
|
||||
renderDetailList('schedule-occurrences', items, error ? '当前凭据没有 schedules:read 触发历史权限。' : '还没有触发记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${String(item.decision || '').toLowerCase()}"></span><div><b>${escapeHTML(item.decision || 'UNKNOWN')} · ${escapeHTML(formatTime(item.scheduled_at))}</b><small>${escapeHTML(item.reason || '—')} · 版本 ${escapeHTML(item.schedule_version || '—')}</small></div><em>${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'}</em></div>`);
|
||||
}
|
||||
function renderScheduleDetail(schedule) {
|
||||
state.selectedSchedule = schedule;
|
||||
text('schedule-detail-title', schedule.name || '定时计划'); text('schedule-detail-id', schedule.id || '—'); text('schedule-detail-state', schedule.paused ? '已暂停' : '运行中'); $('schedule-detail-state').className = `state-pill ${schedule.paused ? 'waiting' : 'succeeded'}`;
|
||||
text('schedule-detail-updated', `最后更新 ${formatTime(schedule.updated_at)}`); text('schedule-detail-name', schedule.name || '—'); text('schedule-detail-queue', shortID(schedule.queue_id)); text('schedule-detail-revision', schedule.revision || '—'); text('schedule-detail-version', schedule.current_version || '—'); text('schedule-detail-next', formatTime(schedule.next_fire_at));
|
||||
const definition = schedule.current?.definition || {}; const rule = definition.rule || {}; text('schedule-detail-timezone', rule.timezone || '—'); text('schedule-detail-rule-kind', rule.kind || '—'); $('schedule-detail-rule').innerHTML = `<b>${escapeHTML(scheduleRuleLabel(rule))}</b><small>漏跑 ${escapeHTML(definition.policy?.misfire || '—')} · 重叠 ${escapeHTML(definition.policy?.overlap || '—')} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}</small>`;
|
||||
$('schedule-actions').hidden = false; text('schedule-control-button', schedule.paused ? '恢复计划' : '暂停计划'); text('schedule-action-note', schedule.paused ? '恢复会按冻结的漏跑策略继续处理暂停期间的触发点。' : '暂停只停止未来触发,已经创建的任务继续执行。');
|
||||
}
|
||||
async function openScheduleDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 schedules:read 权限的凭据'); return; }
|
||||
showScheduleDrawer(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', shortID(id)); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '<div class="detail-loading">正在读取触发记录…</div>';
|
||||
const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]);
|
||||
if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); return; }
|
||||
renderScheduleDetail(extractData(scheduleResult.value)); renderScheduleOccurrences(occurrencesResult.status === 'fulfilled' ? extractItems(occurrencesResult.value) : [], occurrencesResult.status === 'rejected');
|
||||
}
|
||||
function openScheduleAction() {
|
||||
if (!state.selectedSchedule) return;
|
||||
const paused = state.selectedSchedule.paused; $('schedule-action-form').hidden = false; $('schedule-action-error').textContent = ''; $('schedule-action-reason').value = ''; text('schedule-action-title', paused ? '恢复计划' : '暂停计划'); text('schedule-action-description', paused ? '恢复后会按计划冻结的漏跑和重叠策略继续处理。' : '暂停只停止未来触发,不取消已经创建的任务。'); $('schedule-action-reason').focus();
|
||||
}
|
||||
async function submitScheduleAction(event) {
|
||||
event.preventDefault(); if (!state.selectedSchedule) return;
|
||||
const reason = $('schedule-action-reason').value.trim(); if (!reason) { $('schedule-action-error').textContent = '请填写操作原因。'; return; }
|
||||
const paused = state.selectedSchedule.paused; const button = $('schedule-action-submit'); button.disabled = true; $('schedule-action-error').textContent = '';
|
||||
try { await request(`/v1/schedules/${encodeURIComponent(state.selectedSchedule.id)}:${paused ? 'resume' : 'pause'}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(paused ? 'schedule-resume' : 'schedule-pause') }, body: JSON.stringify({ expected_revision: String(state.selectedSchedule.revision), reason }) }); showToast(paused ? '计划已恢复' : '计划已暂停'); closeScheduleAction(); await refresh(); await openScheduleDetail(state.selectedSchedule.id); }
|
||||
catch (error) { $('schedule-action-error').textContent = error.message || '计划控制失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
function showWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
||||
function closeWorkflowAction() { state.workflowActionMode = ''; $('workflow-action-form').hidden = true; $('workflow-action-error').textContent = ''; }
|
||||
function closeWorkflowRecovery() { state.workflowRecoveryMode = ''; state.workflowRecoveryContext = null; $('workflow-recovery-form').hidden = true; $('workflow-recovery-error').textContent = ''; }
|
||||
function closeWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeWorkflowAction(); closeWorkflowRecovery(); }
|
||||
function showSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
||||
function closeSubscriptionAction() { state.subscriptionActionMode = ''; $('subscription-action-form').hidden = true; $('subscription-action-error').textContent = ''; }
|
||||
function closeSubscriptionRetry() { state.selectedDelivery = null; $('subscription-retry-form').hidden = true; $('subscription-retry-error').textContent = ''; }
|
||||
function closeSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeSubscriptionAction(); closeSubscriptionRetry(); }
|
||||
function openWorkflowAction(mode) {
|
||||
if (!state.selectedWorkflow) return;
|
||||
state.workflowActionMode = mode; $('workflow-action-form').hidden = false; $('workflow-action-error').textContent = ''; $('workflow-action-reason').value = '';
|
||||
const labels = { PAUSE: ['暂停流程', '暂停会停止推进新步骤,但已经发出的任务和结果仍会被收集。'], RESUME: ['恢复流程', '恢复只解除暂停,不清除人工核查、取消意图或延长原截止时间。'], CANCEL: ['取消流程', '取消会保存持久意图并停止后继;在途工作仍需结清,未知效果会进入核查。'] };
|
||||
text('workflow-action-title', labels[mode][0]); text('workflow-action-description', labels[mode][1]); text('workflow-action-submit', mode === 'CANCEL' ? '提交取消 →' : '提交控制 →'); $('workflow-action-reason').focus();
|
||||
}
|
||||
async function submitWorkflowAction(event) {
|
||||
event.preventDefault(); if (!state.selectedWorkflow || !state.workflowActionMode) return;
|
||||
const reason = $('workflow-action-reason').value.trim(); if (!reason) { $('workflow-action-error').textContent = '请填写操作原因。'; return; }
|
||||
const workflow = state.selectedWorkflow; const button = $('workflow-action-submit'); button.disabled = true; $('workflow-action-error').textContent = '';
|
||||
try { await request(`/v1/workflows/${encodeURIComponent(workflow.id)}/control`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(`workflow-${state.workflowActionMode.toLowerCase()}`) }, body: JSON.stringify({ operation: state.workflowActionMode, expected_control_revision: String(workflow.control_revision), reason }) }); showToast(state.workflowActionMode === 'CANCEL' ? '流程取消意图已提交' : (state.workflowActionMode === 'PAUSE' ? '流程已暂停' : '流程已恢复')); closeWorkflowAction(); await refresh(); await openWorkflowDetail(workflow.id); }
|
||||
catch (error) { $('workflow-action-error').textContent = error.message || '流程控制失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
const workflowRecoveryLabels = {
|
||||
'task-review': ['核查任务结果', '先核实任务执行已经结清,再记录这次执行的最终结论。'],
|
||||
'step-resolve': ['确认步骤结论', '确认当前步骤的逻辑结果;步骤结果不会自动解除流程核查。'],
|
||||
'continue': ['解除流程核查', '确认全部核查事项已处理,再允许流程继续推进。'],
|
||||
'step-retry': ['安全重试步骤', '仅在确认旧执行不会继续产生外部效果时,创建新的步骤激活。'],
|
||||
'poll-continue': ['接续 PENDING 查询', '确认权威结果仍为 PENDING,沿原查询间隔继续,不重复查询同一轮。'],
|
||||
'wait-rearm': ['重建等待代次', '确认当前等待事实后重建 SIGNAL 或 TIMER 等待代次,原截止时间不延长。'],
|
||||
};
|
||||
function recoveryStepState(step) { return String(step?.state || '').toUpperCase(); }
|
||||
function renderWorkflowRecovery(workflow, payload, error) {
|
||||
const block = $('workflow-recovery-block'); const actions = $('workflow-recovery-actions');
|
||||
block.hidden = workflow?.state !== 'NEEDS_REVIEW'; actions.innerHTML = '';
|
||||
if (block.hidden) return;
|
||||
if (error) { text('workflow-recovery-hint', '步骤读取失败'); actions.innerHTML = '<div class="detail-empty">当前无法读取步骤,暂不提供恢复按钮。</div>'; return; }
|
||||
text('workflow-recovery-hint', '需要 workflows:recover · 所有操作进入审计');
|
||||
const items = Array.isArray(payload?.steps) ? payload.steps : [];
|
||||
const buttons = [{ mode: 'continue', label: '解除流程核查', hint: '全部事项已处理' }];
|
||||
items.forEach(step => {
|
||||
const kind = String(step.kind || '').toUpperCase(); const status = recoveryStepState(step); const context = { step_key: step.key || step.id || '', activation_id: step.activation_id || '', expected_step_revision: step.revision || '', task_id: step.task_id || '' };
|
||||
if (status !== 'NEEDS_REVIEW') return;
|
||||
if (context.task_id) buttons.push({ mode: 'task-review', label: `任务核查 · ${step.key || step.id}`, hint: '记录任务最终结论', context });
|
||||
buttons.push({ mode: 'step-resolve', label: `步骤结论 · ${step.key || step.id}`, hint: `${kind || 'STEP'} · 记录逻辑结果`, context });
|
||||
if (kind === 'TASK' || kind === 'POLL') buttons.push({ mode: 'step-retry', label: `重试步骤 · ${step.key || step.id}`, hint: '确认安全后创建新激活', context });
|
||||
if (kind === 'POLL') buttons.push({ mode: 'poll-continue', label: `接续查询 · ${step.key || step.id}`, hint: '确认 PENDING 后接续', context });
|
||||
if (kind === 'SIGNAL' || kind === 'TIMER') buttons.push({ mode: 'wait-rearm', label: `重建等待 · ${step.key || step.id}`, hint: '确认等待事实后换代', context });
|
||||
});
|
||||
actions.innerHTML = buttons.map(button => `<button class="recovery-action-button" type="button" data-recovery-action="${escapeHTML(button.mode)}" data-recovery-context="${escapeHTML(JSON.stringify(button.context || {}))}"><b>${escapeHTML(button.label)}</b><small>${escapeHTML(button.hint)}</small></button>`).join('');
|
||||
}
|
||||
async function openWorkflowRecovery(mode, context = {}) {
|
||||
if (!state.selectedWorkflow || state.selectedWorkflow.state !== 'NEEDS_REVIEW' || !workflowRecoveryLabels[mode]) return;
|
||||
state.workflowRecoveryMode = mode; state.workflowRecoveryContext = context;
|
||||
const needsStep = ['step-resolve', 'step-retry', 'poll-continue', 'wait-rearm'].includes(mode); const needsTask = mode === 'task-review'; const needsOutcome = ['task-review', 'step-resolve'].includes(mode);
|
||||
$('workflow-recovery-form').hidden = false; $('workflow-recovery-error').textContent = ''; $('workflow-recovery-reason').value = ''; $('workflow-recovery-evidence').value = `evidence:console-${Date.now()}`; $('workflow-recovery-confirm').checked = false;
|
||||
$('workflow-recovery-step-fields').hidden = !needsStep; $('workflow-recovery-task-fields').hidden = !needsTask; $('workflow-recovery-outcome-fields').hidden = !needsOutcome;
|
||||
$('workflow-recovery-result').value = '{}'; $('workflow-recovery-step-key').value = context.step_key || ''; $('workflow-recovery-activation-id').value = context.activation_id || ''; $('workflow-recovery-step-revision').value = context.expected_step_revision || '';
|
||||
$('workflow-recovery-task-id').value = context.task_id || ''; $('workflow-recovery-task-revision').value = '';
|
||||
text('workflow-recovery-title', workflowRecoveryLabels[mode][0]); text('workflow-recovery-description', workflowRecoveryLabels[mode][1]); text('workflow-recovery-submit', `${workflowRecoveryLabels[mode][0]} →`); text('workflow-recovery-snapshot', `当前流程修订 ${state.selectedWorkflow.revision || '—'} · ${context.step_key ? `步骤 ${context.step_key}` : '流程级决定'}`);
|
||||
text('workflow-recovery-confirm-label', mode === 'continue' ? '我确认全部核查事项已经处理,可以解除流程核查。' : '我确认已经核实当前事实,并承担提交此恢复决定的责任。');
|
||||
$('workflow-recovery-reason').focus();
|
||||
if (needsTask && context.task_id) {
|
||||
try { const task = extractData(await request(`/v1/tasks/${encodeURIComponent(context.task_id)}`)); if (!$('workflow-recovery-task-revision').value && task.revision) $('workflow-recovery-task-revision').value = task.revision; }
|
||||
catch { text('workflow-recovery-description', `${workflowRecoveryLabels[mode][1]} 当前任务修订读取失败,请手工填写任务 revision。`); }
|
||||
}
|
||||
}
|
||||
function parseRecoveryResult(value) {
|
||||
try { return JSON.parse(value); } catch { throw new Error('结果必须是合法 JSON,例如 {"status":"DONE"};没有结果时填写 null。'); }
|
||||
}
|
||||
function validRevision(value) { return /^[1-9]\d*$/.test(String(value || '').trim()); }
|
||||
async function submitWorkflowRecovery(event) {
|
||||
event.preventDefault(); if (!state.selectedWorkflow || !state.workflowRecoveryMode) return;
|
||||
const mode = state.workflowRecoveryMode; const workflow = state.selectedWorkflow; const reason = $('workflow-recovery-reason').value.trim(); const evidence = $('workflow-recovery-evidence').value.trim();
|
||||
const error = message => { $('workflow-recovery-error').textContent = message; };
|
||||
if (!reason) { error('请填写核查原因。'); return; }
|
||||
if (!evidence || /[\s\x00-\x1f\x7f]/.test(evidence)) { error('证据引用必填,且不能包含空格或控制字符。'); return; }
|
||||
if (!validRevision(workflow.revision)) { error('当前流程 revision 无效,请重新打开流程详情。'); return; }
|
||||
if (!$('workflow-recovery-confirm').checked) { error('请先确认本次核查决定。'); return; }
|
||||
const body = { expected_revision: String(workflow.revision), reason, evidence_ref: evidence }; let path = `/v1/workflows/${encodeURIComponent(workflow.id)}/recovery/${mode}`;
|
||||
if (mode === 'continue') body.review_resolved = true;
|
||||
if (['step-resolve', 'step-retry', 'poll-continue', 'wait-rearm'].includes(mode)) {
|
||||
const stepKey = $('workflow-recovery-step-key').value.trim(); const activationID = $('workflow-recovery-activation-id').value.trim(); const stepRevision = $('workflow-recovery-step-revision').value.trim();
|
||||
if (!stepKey || !activationID || !validRevision(stepRevision)) { error('请填写当前步骤 key、激活 ID 和步骤 revision。'); return; }
|
||||
Object.assign(body, { step_key: stepKey, activation_id: activationID, expected_step_revision: stepRevision });
|
||||
if (mode === 'step-resolve') { const result = parseRecoveryResult($('workflow-recovery-result').value.trim()); body.execution_settled = true; body.outcome = $('workflow-recovery-outcome').value; body.result = result; }
|
||||
if (mode === 'step-retry') body.safe_to_retry = true;
|
||||
if (mode === 'poll-continue') body.pending_confirmed = true;
|
||||
if (mode === 'wait-rearm') body.wait_verified = true;
|
||||
}
|
||||
if (mode === 'task-review') {
|
||||
const taskID = $('workflow-recovery-task-id').value.trim(); const taskRevision = $('workflow-recovery-task-revision').value.trim(); const result = parseRecoveryResult($('workflow-recovery-result').value.trim());
|
||||
if (!taskID || !validRevision(taskRevision)) { error('请填写待核查任务 ID 和当前任务 revision。'); return; }
|
||||
if (result === null) { error('任务核查的 result 不能为 null,请填写实际 JSON 结果。'); return; }
|
||||
Object.assign(body, { task_id: taskID, expected_task_revision: taskRevision, execution_settled: true, outcome: $('workflow-recovery-outcome').value, result });
|
||||
}
|
||||
const button = $('workflow-recovery-submit'); button.disabled = true; error(''); const workflowID = workflow.id;
|
||||
try { const response = await request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(`workflow-recovery-${mode}`) }, body: JSON.stringify(body) }); showToast(response?.data?.deduplicated ? '已恢复原恢复回执' : `${workflowRecoveryLabels[mode][0]}已提交`); closeWorkflowRecovery(); await refresh(); await openWorkflowDetail(workflowID); }
|
||||
catch (err) { error(err.message || '流程恢复失败'); }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
function renderWorkflowDetail(workflow) {
|
||||
state.selectedWorkflow = workflow;
|
||||
text('workflow-detail-title', workflow.business_key || '流程实例'); text('workflow-detail-id', workflow.id || '—'); text('workflow-detail-state', workflow.state || 'UNKNOWN'); $('workflow-detail-state').className = `state-pill ${stateClass(workflow.state)}`;
|
||||
text('workflow-detail-updated', `最后更新 ${formatTime(workflow.updated_at)}`); text('workflow-detail-definition', workflow.definition_id || '—'); text('workflow-detail-version', workflow.version || '—'); text('workflow-detail-revision', workflow.revision || '—'); text('workflow-detail-control-revision', workflow.control_revision || '—'); text('workflow-detail-business-key', workflow.business_key || '—'); text('workflow-detail-deadline', formatTime(workflow.deadline_at));
|
||||
const outputBlock = $('workflow-output-block'); const hasOutput = Object.prototype.hasOwnProperty.call(workflow, 'output'); outputBlock.hidden = !hasOutput; if (hasOutput) $('workflow-output').textContent = formatJSON(workflow.output) || 'null';
|
||||
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(String(workflow.state || '')); const canCancel = !terminal && !workflow.cancel_requested_at && !workflow.stop_requested_at; $('workflow-actions').hidden = terminal || (!canCancel && !workflow.paused); $('workflow-control-button').hidden = terminal; $('workflow-cancel-button').hidden = !canCancel; text('workflow-control-button', workflow.paused ? '恢复流程' : '暂停流程'); text('workflow-detail-note', workflow.state === 'NEEDS_REVIEW' ? '流程处于人工核查状态;暂停/恢复/取消不会替代带证据的恢复 API。控制写入仍由服务端校验权限、控制修订和审计。' : '流程控制需要 workflows:control 权限;服务端会再次校验控制修订、状态、幂等键和操作原因。');
|
||||
}
|
||||
function renderWorkflowSteps(payload, error) {
|
||||
const items = Array.isArray(payload?.steps) ? payload.steps : [];
|
||||
text('workflow-steps-count', error ? '权限不足' : `${items.length} 个`);
|
||||
renderDetailList('workflow-steps', items, error ? '当前凭据没有 workflows:read 步骤权限。' : '该实例还没有可展示的步骤。', item => {
|
||||
const link = item.task_id ? `任务 ${shortID(item.task_id)}` : (item.activation_id ? `激活 ${shortID(item.activation_id)}` : '未激活');
|
||||
return `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}</b><small>${escapeHTML(item.state || 'UNKNOWN')} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}</small></div><em>${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}</em></div>`;
|
||||
});
|
||||
}
|
||||
function renderWorkflowEvents(payload, error) {
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
text('workflow-events-count', error ? '权限不足' : `${items.length} 条`);
|
||||
renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.type || '状态事件')} · ${escapeHTML(item.state || '—')}</b><small>${escapeHTML(item.state_scope || item.aggregate_kind || 'WORKFLOW')} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}</small></div><em>修订 ${escapeHTML(item.aggregate_revision || '—')}</em></div>`);
|
||||
}
|
||||
async function openWorkflowDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 workflows:read 权限的凭据'); return; }
|
||||
showWorkflowDrawer(); closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
|
||||
const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]);
|
||||
if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; }
|
||||
const workflow = extractData(workflowResult.value); const stepsPayload = stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {};
|
||||
renderWorkflowDetail(workflow);
|
||||
renderWorkflowSteps(stepsPayload, stepsResult.status === 'rejected');
|
||||
renderWorkflowRecovery(workflow, stepsPayload, stepsResult.status === 'rejected');
|
||||
renderWorkflowEvents(eventsResult.status === 'fulfilled' ? extractData(eventsResult.value) : {}, eventsResult.status === 'rejected');
|
||||
}
|
||||
function renderSubscriptionDetail(subscription) {
|
||||
state.selectedSubscription = subscription;
|
||||
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
|
||||
text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.join(', ') : '全部结果类型');
|
||||
$('subscription-actions').hidden = false; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
|
||||
}
|
||||
function renderSubscriptionDeliveries(payload, error) {
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
text('subscription-deliveries-count', error ? '权限不足' : `${items.length} 条`);
|
||||
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
|
||||
$('subscription-deliveries').querySelectorAll('.detail-row-action').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); openSubscriptionRetry(button.dataset.deliveryId, items.find(item => item.id === button.dataset.deliveryId)); }));
|
||||
}
|
||||
function openSubscriptionAction() {
|
||||
if (!state.selectedSubscription) return;
|
||||
state.subscriptionActionMode = state.selectedSubscription.enabled === false ? 'ENABLE' : 'DISABLE'; $('subscription-action-form').hidden = false; $('subscription-action-error').textContent = ''; $('subscription-action-reason').value = '';
|
||||
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); $('subscription-action-reason').focus();
|
||||
}
|
||||
async function submitSubscriptionAction(event) {
|
||||
event.preventDefault(); if (!state.selectedSubscription || !state.subscriptionActionMode) return;
|
||||
const reason = $('subscription-action-reason').value.trim(); if (!reason) { $('subscription-action-error').textContent = '请填写操作原因。'; return; }
|
||||
const subscription = state.selectedSubscription; const enabled = state.subscriptionActionMode === 'ENABLE'; const button = $('subscription-action-submit'); button.disabled = true; $('subscription-action-error').textContent = '';
|
||||
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:configure`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify({ expected_revision: String(subscription.revision), enabled, reason }) }); showToast(enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
|
||||
catch (error) { $('subscription-action-error').textContent = error.message || '订阅控制失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
function openSubscriptionRetry(id, delivery) {
|
||||
if (!state.selectedSubscription || !id || !delivery) return;
|
||||
state.selectedDelivery = delivery; $('subscription-retry-form').hidden = false; $('subscription-retry-error').textContent = ''; $('subscription-retry-reason').value = ''; $('subscription-retry-attempts').value = '1'; $('subscription-retry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); $('subscription-retry-reason').focus();
|
||||
}
|
||||
async function submitSubscriptionRetry(event) {
|
||||
event.preventDefault(); if (!state.selectedSubscription || !state.selectedDelivery) return;
|
||||
const reason = $('subscription-retry-reason').value.trim(); if (!reason) { $('subscription-retry-error').textContent = '请填写操作原因。'; return; }
|
||||
const additionalAttempts = Number($('subscription-retry-attempts').value); if (!Number.isSafeInteger(additionalAttempts) || additionalAttempts < 1 || additionalAttempts > 100) { $('subscription-retry-error').textContent = '追加次数必须是 1 到 100 的整数。'; return; }
|
||||
const subscription = state.selectedSubscription; const delivery = state.selectedDelivery; const button = $('subscription-retry-submit'); button.disabled = true; $('subscription-retry-error').textContent = '';
|
||||
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}/deliveries/${encodeURIComponent(delivery.id)}:retry`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey('subscription-delivery-retry') }, body: JSON.stringify({ expected_revision: String(delivery.revision), reason, additional_attempts: additionalAttempts }) }); showToast('过期交付已追加预算并重新排队'); closeSubscriptionRetry(); await refresh(); await openSubscriptionDetail(subscription.id); }
|
||||
catch (error) { $('subscription-retry-error').textContent = error.message || '交付重试失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
async function openSubscriptionDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 events:read 权限的凭据'); return; }
|
||||
showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
|
||||
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
|
||||
if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; }
|
||||
renderSubscriptionDetail(extractData(subscriptionResult.value)); renderSubscriptionDeliveries(deliveriesResult.status === 'fulfilled' ? extractData(deliveriesResult.value) : {}, deliveriesResult.status === 'rejected');
|
||||
}
|
||||
async function refresh() {
|
||||
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
||||
try {
|
||||
await loadHealth();
|
||||
if (!state.token || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
||||
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems);
|
||||
const optional = await Promise.allSettled([
|
||||
request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
||||
request('/v1/workflows?limit=20'),
|
||||
request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
||||
]);
|
||||
const scheduleItems = optional[0].status === 'fulfilled' ? extractItems(optional[0].value) : null;
|
||||
const workflowItems = optional[1].status === 'fulfilled' ? extractItems(optional[1].value) : null;
|
||||
const subscriptionItems = optional[2].status === 'fulfilled' ? extractItems(optional[2].value) : null;
|
||||
if (scheduleItems) renderSchedules(scheduleItems);
|
||||
if (workflowItems) renderWorkflows(workflowItems);
|
||||
if (subscriptionItems) renderSubscriptions(subscriptionItems);
|
||||
setConnection(true); renderRuntimeStats(taskItems, scheduleItems, workflowItems, subscriptionItems); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); text('metric-health-sub', '数据库与 Schema 正常');
|
||||
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
|
||||
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
||||
}
|
||||
function connect() {
|
||||
state.base = $('api-url').value.trim().replace(/\/$/, '') || location.origin; state.token = $('token').value.trim(); state.queue = $('queue-id').value.trim() || String(state.consoleConfig?.default_queue_id || '').trim();
|
||||
$('queue-id').value = state.queue;
|
||||
sessionStorage.setItem('orbit.console.queue', state.queue);
|
||||
if (!state.token) showToast('服务状态可以直接查看;选择凭据 JSON 后可读取业务数据');
|
||||
else if (!state.queue) showToast('队列会从凭据 JSON 自动读取,也可配置 ORBIT_CONSOLE_QUEUE_ID');
|
||||
closeConnectionSettings();
|
||||
refresh();
|
||||
}
|
||||
$('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', openConnectionSettings); $('connection-settings-button').addEventListener('click', openConnectionSettings); $('connection-settings-inline').addEventListener('click', openConnectionSettings); $('close-connection-settings').addEventListener('click', closeConnectionSettings); $('connection-settings-backdrop').addEventListener('click', event => { if (event.target.id === 'connection-settings-backdrop') closeConnectionSettings(); }); $('refresh-button').addEventListener('click', refresh);
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('connection-settings-backdrop').hidden) closeConnectionSettings(); else if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
|
||||
$('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; });
|
||||
$('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); const queue = String(credential.queue_id || state.consoleConfig?.default_queue_id || '').trim(); $('token').value = credential.token || ''; $('queue-id').value = queue; state.queue = queue; state.token = String(credential.token || '').trim(); sessionStorage.setItem('orbit.console.queue', queue); text('queue-hint', queue ? '队列已从凭据 JSON 自动读取;无需手工填写。' : '凭据未附带队列,请配置 ORBIT_CONSOLE_QUEUE_ID。'); renderConnectionSummary(); showToast(queue ? '凭据已读取,队列已自动填充' : '凭据已读取,但缺少队列配置'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); });
|
||||
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
||||
window.addEventListener('hashchange', () => setView(currentView(), false));
|
||||
setView(currentView(), false);
|
||||
loadConsoleConfig().finally(() => refresh()); state.timer = setInterval(refresh, 10000);
|
||||
})();
|
||||
309
internal/transport/httpserver/console/index.html
Normal file
309
internal/transport/httpserver/console/index.html
Normal file
@@ -0,0 +1,309 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Orbit Console · 星轨控制台</title>
|
||||
<link rel="stylesheet" href="/console/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-one"></div>
|
||||
<div class="ambient ambient-two"></div>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><span></span><span></span><span></span></div>
|
||||
<div><strong>Orbit</strong><small>星轨控制台</small></div>
|
||||
</div>
|
||||
<div class="workspace-label">CONTROL PLANE</div>
|
||||
<nav class="nav-list" aria-label="控制台导航">
|
||||
<button class="nav-item active" data-view="overview"><span class="nav-icon">⌂</span><span>总览</span><em>01</em></button>
|
||||
<button class="nav-item" data-view="tasks"><span class="nav-icon">◇</span><span>任务执行</span><em>02</em></button>
|
||||
<button class="nav-item" data-view="schedules"><span class="nav-icon">◷</span><span>定时计划</span><em>03</em></button>
|
||||
<button class="nav-item" data-view="workflows"><span class="nav-icon">⌘</span><span>流程实例</span><em>04</em></button>
|
||||
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="rail-card">
|
||||
<div class="rail-card-top"><span class="status-dot"></span><span id="side-status">等待连接</span></div>
|
||||
<p id="side-endpoint">API 未连接</p>
|
||||
<div class="rail-progress"><span id="side-progress"></span></div>
|
||||
</div>
|
||||
<div class="sidebar-foot"><span class="pulse-dot"></span> Pulse · Weave <span class="foot-version">v1</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="topbar">
|
||||
<div class="crumbs"><span>Orbit</span><b>/</b><strong id="page-title">总览</strong></div>
|
||||
<div class="topbar-actions">
|
||||
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
||||
<span class="last-updated" id="last-updated">尚未刷新</span>
|
||||
<button class="icon-button" id="connection-settings-button" title="连接设置" aria-label="连接设置">⚙</button>
|
||||
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-view active" id="view-overview" data-page="overview">
|
||||
<section class="hero" id="overview">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow"><span class="eyebrow-line"></span> ORBIT CONTROL PLANE</div>
|
||||
<h1>让每一次执行<br><span>都清晰可见。</span></h1>
|
||||
<p>任务、定时、流程和交付状态,在一块屏幕上保持有序运转。</p>
|
||||
<div class="hero-actions">
|
||||
<button class="primary-button" data-view="tasks">查看任务 <span>↗</span></button>
|
||||
<button class="quiet-button" id="connect-hero">连接设置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual" aria-hidden="true">
|
||||
<div class="orbit-ring ring-one"></div>
|
||||
<div class="orbit-ring ring-two"></div>
|
||||
<div class="orbit-ring ring-three"></div>
|
||||
<div class="orbit-core"><span>O</span></div>
|
||||
<div class="orbit-node node-a"></div><div class="orbit-node node-b"></div><div class="orbit-node node-c"></div>
|
||||
<div class="hero-visual-label"><b>LIVE</b><span>任务流持续观测</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="运行概览">
|
||||
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="metric-icon green">✦</span></div><strong id="metric-health">—</strong><small id="metric-health-sub">等待就绪检查</small><div class="sparkline green-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>任务总览</span><span class="metric-icon purple">◇</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">连接凭据后加载</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>流程实例</span><span class="metric-icon blue">⌘</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">等待工作流权限</small><div class="sparkline blue-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>结果订阅</span><span class="metric-icon orange">⌁</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">等待事件权限</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></article>
|
||||
</section>
|
||||
|
||||
<section class="content-grid overview-grid" aria-label="运行统计与连接状态">
|
||||
<article class="panel stats-panel" id="runtime-stats">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">RUNTIME / SNAPSHOT</span><h2>运行统计</h2></div><span class="table-count" id="stats-snapshot">等待数据</span></div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-tile"><span>当前窗口成功率</span><strong id="stat-success-rate">—</strong><small id="stat-success-rate-sub">等待任务数据</small></div>
|
||||
<div class="stat-tile"><span>活跃计划</span><strong id="stat-active-schedules">—</strong><small id="stat-active-schedules-sub">等待计划数据</small></div>
|
||||
<div class="stat-tile"><span>待核查流程</span><strong id="stat-review-workflows">—</strong><small id="stat-review-workflows-sub">等待流程数据</small></div>
|
||||
<div class="stat-tile"><span>待交付结果</span><strong id="stat-pending-deliveries">—</strong><small id="stat-pending-deliveries-sub">等待订阅数据</small></div>
|
||||
</div>
|
||||
<div class="distribution-block">
|
||||
<div class="distribution-heading"><span>任务状态分布</span><small id="stat-task-total">—</small></div>
|
||||
<div class="distribution-list">
|
||||
<div class="distribution-row"><span>成功</span><div class="status-track"><i id="stat-bar-succeeded"></i></div><b id="stat-count-succeeded">0</b></div>
|
||||
<div class="distribution-row"><span>执行中</span><div class="status-track"><i id="stat-bar-running"></i></div><b id="stat-count-running">0</b></div>
|
||||
<div class="distribution-row"><span>等待</span><div class="status-track"><i id="stat-bar-waiting"></i></div><b id="stat-count-waiting">0</b></div>
|
||||
<div class="distribution-row"><span>异常</span><div class="status-track"><i id="stat-bar-other"></i></div><b id="stat-count-other">0</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="panel connection-summary-panel">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">CONTROL PLANE</span><h2>连接状态</h2></div><span class="connection-state" id="connection-state">未连接</span></div>
|
||||
<div class="connection-summary-copy"><span class="connection-summary-icon">↗</span><div><b id="summary-api">当前页面</b><small id="summary-api-hint">API 地址自动跟随当前页面</small></div></div>
|
||||
<div class="connection-summary-grid"><div><span>队列范围</span><b id="summary-queue">自动配置</b></div><div><span>凭据状态</span><b id="summary-credential">未载入</b></div></div>
|
||||
<p class="connection-summary-note" id="summary-note">服务状态会自动检查;需要业务数据时再加载凭据。</p>
|
||||
<button class="quiet-button" id="connection-settings-inline" type="button">管理连接设置 <span>→</span></button>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-tasks" data-page="tasks">
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel wide-panel" id="tasks">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-view="overview">返回总览 <span>←</span></button></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>任务</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>连接一个具备 tasks:read 权限的凭据后自动加载</small></td></tr></tbody></table></div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-schedules" data-page="schedules">
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel" id="schedules"><div class="panel-heading"><div><span class="panel-kicker">PULSE / SCHEDULES</span><h2>定时计划</h2></div><span class="table-count" id="schedule-page-hint">点击计划查看详情</span></div><div class="schedule-list" id="schedule-list"><div class="empty-compact"><span>◷</span><div><b>等待计划数据</b><small>需要 schedules:read 权限</small></div></div></div></article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-workflows" data-page="workflows">
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel" id="workflows"><div class="panel-heading"><div><span class="panel-kicker">WEAVE / WORKFLOWS</span><h2>流程实例</h2></div><span class="table-count">点击实例查看详情</span></div><div class="workflow-list" id="workflow-list"><div class="empty-compact"><span>⌘</span><div><b>等待流程数据</b><small>需要 workflows:read 权限</small></div></div></div></article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-subscriptions" data-page="subscriptions">
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel" id="subscriptions"><div class="panel-heading"><div><span class="panel-kicker">EVENTS / DELIVERY</span><h2>结果订阅</h2></div><span class="live-label-small"><i></i>自动刷新</span></div><div class="subscription-list" id="subscription-list"><div class="empty-compact"><span>⌁</span><div><b>等待订阅数据</b><small>需要 events:read 权限</small></div></div></div></article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||
</main>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="connection-settings-backdrop" hidden>
|
||||
<aside class="task-drawer connection-drawer" role="dialog" aria-modal="true" aria-labelledby="connection-settings-title">
|
||||
<div class="drawer-header">
|
||||
<div><span class="panel-kicker">CONTROL PLANE / SETTINGS</span><h2 id="connection-settings-title">连接设置</h2><small class="drawer-id">凭据只在当前浏览器会话中使用</small></div>
|
||||
<button class="icon-button" id="close-connection-settings" type="button" aria-label="关闭连接设置">×</button>
|
||||
</div>
|
||||
<p class="drawer-note connection-settings-intro">API 地址默认使用当前页面;队列优先读取服务端配置或凭据 JSON。这里不会读取数据库,也不会上传凭据。</p>
|
||||
<label class="field-label" for="api-url">API 地址 <small>自动使用当前页面</small></label><div class="input-wrap"><span class="input-prefix">↗</span><input id="api-url" type="url" value="" placeholder="http://127.0.0.1:8080"></div>
|
||||
<label class="field-label" for="queue-id">队列范围 <small>自动读取</small></label><div class="input-wrap"><span class="input-prefix">#</span><input id="queue-id" type="text" autocomplete="off" placeholder="凭据 JSON 或服务端配置会自动填充"></div><div class="field-hint" id="queue-hint">选择凭据 JSON 后自动填充;也可由服务端配置默认队列。</div>
|
||||
<label class="field-label" for="token">Bearer Token</label><div class="input-wrap"><span class="input-prefix">●</span><input id="token" type="password" placeholder="粘贴开发凭据 token"><button class="reveal-button" id="reveal-token" type="button">显示</button></div>
|
||||
<div class="connect-row"><label class="file-button"><input id="credential-file" type="file" accept="application/json">选择凭据 JSON</label><button class="primary-button compact" id="connect-button" type="button">连接 <span>→</span></button></div>
|
||||
<div class="security-note"><span>▣</span><span>当前页面只调用公开 API 和授权接口,不读取数据库。</span></div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
||||
<div class="drawer-header">
|
||||
<div><span class="panel-kicker">PULSE / TASK DETAIL</span><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small></div>
|
||||
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="detail-state">—</span><span id="detail-updated">—</span></div>
|
||||
<div class="detail-grid">
|
||||
<div><span>处理器</span><b id="detail-handler">—</b></div>
|
||||
<div><span>修订</span><b id="detail-revision">—</b></div>
|
||||
<div><span>执行尝试</span><b id="detail-attempt-count">—</b></div>
|
||||
<div><span>效果状态</span><b id="detail-effect">—</b></div>
|
||||
<div><span>创建时间</span><b id="detail-created">—</b></div>
|
||||
<div><span>截止时间</span><b id="detail-deadline">—</b></div>
|
||||
</div>
|
||||
<div class="detail-block" id="detail-result-block" hidden><div class="detail-block-heading"><span>最终结果</span><small>只读快照</small></div><pre id="detail-result"></pre></div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>执行时间线</span><small id="detail-attempts-count">加载中</small></div><div class="detail-list" id="detail-attempts"><div class="detail-loading">正在读取尝试历史…</div></div></div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>操作审计</span><small id="detail-operations-count">加载中</small></div><div class="detail-list" id="detail-operations"><div class="detail-loading">正在读取操作审计…</div></div></div>
|
||||
<div class="drawer-actions" id="detail-actions" hidden>
|
||||
<button class="quiet-button danger-button" id="task-cancel-button" type="button">取消任务</button>
|
||||
<button class="primary-button compact" id="task-retry-button" type="button">安全重试 <span>→</span></button>
|
||||
</div>
|
||||
<p class="drawer-note" id="detail-action-note">写操作需要对应的管理权限;服务端会再次校验版本、状态、幂等键和队列范围。</p>
|
||||
<form class="action-form" id="task-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker" id="action-kicker">TASK CONTROL</span><h3 id="action-title">任务操作</h3></div>
|
||||
<p id="action-description"></p>
|
||||
<label class="field-label" for="action-reason">操作原因</label><textarea id="action-reason" rows="3" maxlength="1024" placeholder="说明本次操作的原因"></textarea>
|
||||
<div id="action-evidence-wrap"><label class="field-label" for="action-evidence">证据引用</label><input id="action-evidence" type="text" maxlength="512" placeholder="例如 evidence:reconcile-001"></div>
|
||||
<div id="action-timeout-wrap"><label class="field-label" for="action-timeout">新任务总时限(毫秒)</label><input id="action-timeout" type="number" min="1" max="2592000000" step="1" value="60000"></div>
|
||||
<label class="check-row" id="action-safe-wrap"><input id="action-safe" type="checkbox"><span>我确认旧执行已经结束,按原业务身份重新执行是安全的。</span></label>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="action-cancel" type="button">返回</button><button class="primary-button compact" id="action-submit" type="submit">提交操作 <span>→</span></button></div>
|
||||
<div class="action-error" id="action-error" role="alert"></div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="schedule-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer schedule-drawer" role="dialog" aria-modal="true" aria-labelledby="schedule-detail-title">
|
||||
<div class="drawer-header">
|
||||
<div><span class="panel-kicker">PULSE / SCHEDULE DETAIL</span><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small></div>
|
||||
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="schedule-detail-state">—</span><span id="schedule-detail-updated">—</span></div>
|
||||
<div class="detail-grid">
|
||||
<div><span>计划名称</span><b id="schedule-detail-name">—</b></div>
|
||||
<div><span>队列</span><b id="schedule-detail-queue">—</b></div>
|
||||
<div><span>控制修订</span><b id="schedule-detail-revision">—</b></div>
|
||||
<div><span>当前版本</span><b id="schedule-detail-version">—</b></div>
|
||||
<div><span>下一次触发</span><b id="schedule-detail-next">—</b></div>
|
||||
<div><span>时区</span><b id="schedule-detail-timezone">—</b></div>
|
||||
</div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>当前版本规则</span><small id="schedule-detail-rule-kind">—</small></div><div class="schedule-rule-card" id="schedule-detail-rule">等待计划数据…</div></div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>最近触发记录</span><small id="schedule-occurrences-count">加载中</small></div><div class="detail-list" id="schedule-occurrences"><div class="detail-loading">正在读取触发记录…</div></div></div>
|
||||
<div class="drawer-actions" id="schedule-actions" hidden><button class="quiet-button" id="schedule-control-button" type="button">暂停计划</button></div>
|
||||
<p class="drawer-note" id="schedule-action-note">计划控制需要 schedules:manage 权限;暂停只停止未来触发,已经创建的任务继续执行。</p>
|
||||
<form class="action-form" id="schedule-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">SCHEDULE CONTROL</span><h3 id="schedule-action-title">暂停计划</h3></div>
|
||||
<p id="schedule-action-description">暂停后不会再创建新的任务。</p>
|
||||
<label class="field-label" for="schedule-action-reason">操作原因</label><textarea id="schedule-action-reason" rows="3" maxlength="1024" placeholder="说明本次计划控制的原因"></textarea>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="schedule-action-cancel" type="button">返回</button><button class="primary-button compact" id="schedule-action-submit" type="submit">提交控制 →</button></div>
|
||||
<div class="action-error" id="schedule-action-error" role="alert"></div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="workflow-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer workflow-drawer" role="dialog" aria-modal="true" aria-labelledby="workflow-detail-title">
|
||||
<div class="drawer-header">
|
||||
<div><span class="panel-kicker">WEAVE / WORKFLOW DETAIL</span><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small></div>
|
||||
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="workflow-detail-state">—</span><span id="workflow-detail-updated">—</span></div>
|
||||
<div class="detail-grid">
|
||||
<div><span>定义</span><b id="workflow-detail-definition">—</b></div>
|
||||
<div><span>固定版本</span><b id="workflow-detail-version">—</b></div>
|
||||
<div><span>实例修订</span><b id="workflow-detail-revision">—</b></div>
|
||||
<div><span>控制修订</span><b id="workflow-detail-control-revision">—</b></div>
|
||||
<div><span>业务键</span><b id="workflow-detail-business-key">—</b></div>
|
||||
<div><span>总截止时间</span><b id="workflow-detail-deadline">—</b></div>
|
||||
</div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>步骤状态</span><small id="workflow-steps-count">加载中</small></div><div class="detail-list" id="workflow-steps"><div class="detail-loading">正在读取步骤…</div></div></div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>事件时间线</span><small id="workflow-events-count">加载中</small></div><div class="detail-list" id="workflow-events"><div class="detail-loading">正在读取事件…</div></div></div>
|
||||
<div class="detail-block" id="workflow-output-block" hidden><div class="detail-block-heading"><span>流程输出</span><small>只读快照</small></div><pre id="workflow-output"></pre></div>
|
||||
<div class="detail-block recovery-block" id="workflow-recovery-block" hidden>
|
||||
<div class="detail-block-heading"><span>人工核查与恢复</span><small id="workflow-recovery-hint">需要 workflows:recover</small></div>
|
||||
<p class="recovery-intro">只在 NEEDS_REVIEW 状态显示。每个决定都要引用外部证据,并使用当前流程、步骤和激活修订;服务端会再次校验所有门控。</p>
|
||||
<div class="recovery-action-grid" id="workflow-recovery-actions"></div>
|
||||
</div>
|
||||
<div class="drawer-actions" id="workflow-actions" hidden><button class="quiet-button" id="workflow-control-button" type="button">暂停流程</button><button class="quiet-button danger-button" id="workflow-cancel-button" type="button">取消流程</button></div>
|
||||
<p class="drawer-note" id="workflow-detail-note">流程详情只读取当前权威视图;事件审计需要 workflows:audit:read 权限,所有控制操作仍由服务端权限和并发版本校验。</p>
|
||||
<form class="action-form" id="workflow-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">WEAVE CONTROL</span><h3 id="workflow-action-title">流程控制</h3></div>
|
||||
<p id="workflow-action-description">流程控制会写入不可变审计,并使用当前控制修订进行并发校验。</p>
|
||||
<label class="field-label" for="workflow-action-reason">操作原因</label><textarea id="workflow-action-reason" rows="3" maxlength="1024" placeholder="说明本次流程控制的原因"></textarea>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="workflow-action-cancel" type="button">返回</button><button class="primary-button compact" id="workflow-action-submit" type="submit">提交控制 →</button></div>
|
||||
<div class="action-error" id="workflow-action-error" role="alert"></div>
|
||||
</form>
|
||||
<form class="action-form recovery-form" id="workflow-recovery-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">WEAVE RECOVERY</span><h3 id="workflow-recovery-title">人工恢复</h3></div>
|
||||
<p id="workflow-recovery-description">提交后会写入流程审计,并按当前版本重新读取实例。</p>
|
||||
<div class="recovery-snapshot" id="workflow-recovery-snapshot">当前流程修订 — · 当前步骤 —</div>
|
||||
<label class="field-label" for="workflow-recovery-reason">核查原因</label><textarea id="workflow-recovery-reason" rows="3" maxlength="1024" placeholder="说明你核实了什么事实,以及为什么可以执行该决定"></textarea>
|
||||
<label class="field-label" for="workflow-recovery-evidence">证据引用</label><input id="workflow-recovery-evidence" type="text" maxlength="512" placeholder="例如 evidence:query-001(不能含空格)">
|
||||
<div id="workflow-recovery-step-fields" hidden>
|
||||
<label class="field-label" for="workflow-recovery-step-key">步骤键</label><input id="workflow-recovery-step-key" type="text" maxlength="64" placeholder="当前步骤 key">
|
||||
<label class="field-label" for="workflow-recovery-activation-id">激活 ID</label><input id="workflow-recovery-activation-id" type="text" maxlength="32" placeholder="当前激活代次 ID">
|
||||
<label class="field-label" for="workflow-recovery-step-revision">步骤修订</label><input id="workflow-recovery-step-revision" type="number" min="1" step="1" placeholder="当前步骤 revision">
|
||||
</div>
|
||||
<div id="workflow-recovery-task-fields" hidden>
|
||||
<label class="field-label" for="workflow-recovery-task-id">任务 ID</label><input id="workflow-recovery-task-id" type="text" maxlength="32" placeholder="待核查任务 ID">
|
||||
<label class="field-label" for="workflow-recovery-task-revision">任务修订</label><input id="workflow-recovery-task-revision" type="number" min="1" step="1" placeholder="当前任务 revision">
|
||||
</div>
|
||||
<div class="recovery-inline-fields" id="workflow-recovery-outcome-fields" hidden>
|
||||
<div><label class="field-label" for="workflow-recovery-outcome">结论</label><select id="workflow-recovery-outcome"><option value="SUCCEEDED">SUCCEEDED · 成功</option><option value="FAILED">FAILED · 失败</option></select></div>
|
||||
<div id="workflow-recovery-result-wrap"><label class="field-label" for="workflow-recovery-result">结果 JSON</label><textarea id="workflow-recovery-result" rows="3" placeholder="例如 {"status":"DONE"};TIMER 成功可填 null"></textarea></div>
|
||||
</div>
|
||||
<label class="check-row" id="workflow-recovery-confirm-row"><input id="workflow-recovery-confirm" type="checkbox"><span id="workflow-recovery-confirm-label">我确认已核实当前事项,可以提交这个恢复决定。</span></label>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="workflow-recovery-cancel" type="button">返回</button><button class="primary-button compact" id="workflow-recovery-submit" type="submit">提交恢复 →</button></div>
|
||||
<div class="action-error" id="workflow-recovery-error" role="alert"></div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="subscription-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer subscription-drawer" role="dialog" aria-modal="true" aria-labelledby="subscription-detail-title">
|
||||
<div class="drawer-header">
|
||||
<div><span class="panel-kicker">EVENTS / SUBSCRIPTION DETAIL</span><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small></div>
|
||||
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="subscription-detail-state">—</span><span id="subscription-detail-updated">—</span></div>
|
||||
<div class="detail-grid">
|
||||
<div><span>订阅名称</span><b id="subscription-detail-name">—</b></div>
|
||||
<div><span>队列</span><b id="subscription-detail-queue">—</b></div>
|
||||
<div><span>配置修订</span><b id="subscription-detail-revision">—</b></div>
|
||||
<div><span>待确认交付</span><b id="subscription-detail-pending">—</b></div>
|
||||
<div><span>未确认上限</span><b id="subscription-detail-max-pending">—</b></div>
|
||||
<div><span>单条次数预算</span><b id="subscription-detail-max-attempts">—</b></div>
|
||||
<div><span>游标序号</span><b id="subscription-detail-cursor">—</b></div>
|
||||
<div><span>事件类型</span><b id="subscription-detail-types">—</b></div>
|
||||
</div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>最近交付</span><small id="subscription-deliveries-count">加载中</small></div><div class="detail-list" id="subscription-deliveries"><div class="detail-loading">正在读取交付历史…</div></div></div>
|
||||
<div class="drawer-actions" id="subscription-actions" hidden><button class="quiet-button" id="subscription-control-button" type="button">暂停订阅</button></div>
|
||||
<p class="drawer-note" id="subscription-detail-note">交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。</p>
|
||||
<form class="action-form" id="subscription-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">EVENTS CONTROL</span><h3 id="subscription-action-title">订阅控制</h3></div>
|
||||
<p id="subscription-action-description">订阅控制会保留已有交付和审计记录。</p>
|
||||
<label class="field-label" for="subscription-action-reason">操作原因</label><textarea id="subscription-action-reason" rows="3" maxlength="1024" placeholder="说明本次订阅控制的原因"></textarea>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="subscription-action-cancel" type="button">返回</button><button class="primary-button compact" id="subscription-action-submit" type="submit">提交控制 →</button></div>
|
||||
<div class="action-error" id="subscription-action-error" role="alert"></div>
|
||||
</form>
|
||||
<form class="action-form" id="subscription-retry-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">EVENTS RECOVERY</span><h3>重试过期交付</h3></div>
|
||||
<p>只允许对 EXPIRED 交付追加次数预算;原交付身份、已用次数和事件不会重置。</p>
|
||||
<label class="field-label" for="subscription-retry-reason">操作原因</label><textarea id="subscription-retry-reason" rows="3" maxlength="1024" placeholder="说明接收方已修复的原因"></textarea>
|
||||
<label class="field-label" for="subscription-retry-attempts">追加次数(1–100)</label><input id="subscription-retry-attempts" type="number" min="1" max="100" step="1" value="1">
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="subscription-retry-cancel" type="button">返回</button><button class="primary-button compact" id="subscription-retry-submit" type="submit">追加预算并重试 →</button></div>
|
||||
<div class="action-error" id="subscription-retry-error" role="alert"></div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status"></div>
|
||||
<script src="/console/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
internal/transport/httpserver/console/styles.css
Normal file
8
internal/transport/httpserver/console/styles.css
Normal file
File diff suppressed because one or more lines are too long
58
internal/transport/httpserver/console_test.go
Normal file
58
internal/transport/httpserver/console_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConsoleIsEmbeddedAndDoesNotRequireCredentials(t *testing.T) {
|
||||
server := New("127.0.0.1:0", nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/console/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); !strings.Contains(got, "text/html") {
|
||||
t.Fatalf("content type = %q, want HTML", got)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Orbit Console") {
|
||||
t.Fatal("embedded console page is missing its title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleRedirectsWithoutTouchingAPIAuthentication(t *testing.T) {
|
||||
server := New("127.0.0.1:0", nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusPermanentRedirect || w.Header().Get("Location") != "/console/" {
|
||||
t.Fatalf("redirect = %d %q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleConfigReadsOnlyDefaultQueue(t *testing.T) {
|
||||
t.Setenv("ORBIT_CONSOLE_QUEUE_ID", "queue-default")
|
||||
server := New("127.0.0.1:0", nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/console/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("cache control = %q, want no-store", got)
|
||||
}
|
||||
var payload struct {
|
||||
DefaultQueueID string `json:"default_queue_id"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
if payload.DefaultQueueID != "queue-default" {
|
||||
t.Fatalf("default queue = %q, want queue-default", payload.DefaultQueueID)
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,9 @@ func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
|
||||
registerTasks(mux, tasks[0])
|
||||
registerHistory(mux, tasks[0])
|
||||
}
|
||||
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||
server := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||
RegisterConsole(server)
|
||||
return server
|
||||
}
|
||||
|
||||
// WrapMetrics must run after all Register* functions because those functions
|
||||
|
||||
Reference in New Issue
Block a user