fix: complete retention archive pagination and validation
This commit is contained in:
@@ -52,7 +52,7 @@
|
|||||||
| platform | `AuditEvent` | `orbit_audit_events` |
|
| platform | `AuditEvent` | `orbit_audit_events` |
|
||||||
| platform | `ConsoleUser` | `orbit_console_users` |
|
| platform | `ConsoleUser` | `orbit_console_users` |
|
||||||
| platform | `ConsoleSession` | `orbit_console_sessions` |
|
| platform | `ConsoleSession` | `orbit_console_sessions` |
|
||||||
| platform | `RetentionPolicy` | `orbit_retention_policies` |
|
| internal/retention | `Policy` | `orbit_retention_policies` |
|
||||||
| pulse | `Queue` | `pulse_queues` |
|
| pulse | `Queue` | `pulse_queues` |
|
||||||
| pulse | `ExecutionGate` | `pulse_execution_gates` |
|
| pulse | `ExecutionGate` | `pulse_execution_gates` |
|
||||||
| pulse | `WorkerSession` | `pulse_worker_sessions` |
|
| pulse | `WorkerSession` | `pulse_worker_sessions` |
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
控制台六个核心模块已拆为独立 hash 视图,导航状态和页面标题随视图同步,刷新或直接打开对应 hash 后仍保持正确模块。数据请求继续由统一会话和当前命名空间范围驱动。
|
控制台六个核心模块已拆为独立 hash 视图,导航状态和页面标题随视图同步,刷新或直接打开对应 hash 后仍保持正确模块。数据请求继续由统一会话和当前命名空间范围驱动。
|
||||||
|
|
||||||
已完成一次真实链路回归:标准 `scripts/test-mysql.sh` 在一次性 MySQL 和真实 Orbit 进程中跑通任务、Worker、结果订阅、Weave 流程、进程恢复及独立业务示例;控制台六个入口、健康检查和详情所依赖的读取 API 已用真实结果核对,保留策略页面另有真实 MySQL 策略回归,证据见 [Console 真实链路回归](reports/phase-7-console-e2e.md)。这关闭了本地闭环验证项,生产拓扑和发布门槛仍保持未验收。
|
已完成一次真实链路回归:标准 `scripts/test-mysql.sh` 在一次性 MySQL 和真实 Orbit 进程中跑通任务、Worker、结果订阅、Weave 流程、进程恢复及独立业务示例;控制台前五个入口、健康检查和详情所依赖的读取 API 已用真实结果核对;第六个保留策略入口另有真实 MySQL、首次部署 HTTP 与浏览器操作回归,证据见 [Console 真实链路回归](reports/phase-7-console-e2e.md)。这关闭了本地闭环验证项,生产拓扑和发布门槛仍保持未验收。
|
||||||
|
|
||||||
新增 `make console-e2e` 作为首次部署闭环门禁:在一次性 MySQL 和 Docker Orbit 中从空库迁移、初始化控制台管理员,完成登录、受保护页面、WS Worker 成功执行、明确失败重试、定时任务、控制台任务读取和登出后的 401 校验,证据见[首次部署与控制台任务闭环](reports/phase-8-console-e2e.md)。该门禁证明功能链路可重复,不替代目标生产拓扑、容量和 RPO/RTO 验收。
|
新增 `make console-e2e` 作为首次部署闭环门禁:在一次性 MySQL 和 Docker Orbit 中从空库迁移、初始化控制台管理员,完成登录、受保护页面、WS Worker 成功执行、明确失败重试、定时任务、控制台任务读取和登出后的 401 校验,证据见[首次部署与控制台任务闭环](reports/phase-8-console-e2e.md)。该门禁证明功能链路可重复,不替代目标生产拓扑、容量和 RPO/RTO 验收。
|
||||||
|
|
||||||
@@ -109,8 +109,8 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
|
|||||||
1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。迁移器同时提供当前版本无外键新装基线;每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。数据库事务封装另对提交前 MySQL 1205/1213 做最多两次短退避重试,Commit 回执不明保持原身份核对语义,真实锁环回归见[P7当前回归状态](reports/phase-7-regression-status.md)。
|
1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。迁移器同时提供当前版本无外键新装基线;每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。数据库事务封装另对提交前 MySQL 1205/1213 做最多两次短退避重试,Commit 回执不明保持原身份核对语义,真实锁环回归见[P7当前回归状态](reports/phase-7-regression-status.md)。
|
||||||
2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定任务/交付/流程/Inbox状态计数、最近事件类别、终态耗时样本、固定事件阶段耗时、最早年龄、截断/新鲜度指标、提交前 MySQL 事务重试/耗尽计数和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签;发布前只读结构核验由[`orbit-admin verify`](api/operations.md)提供,运维人员也可用 `orbit-admin observe snapshot` 获取同一有界单节点快照;后台循环间隔、批量上限、Worker/Event连接上限、Hub领取协程和有界内存排队均支持有界环境配置,并在启动日志和固定指标中记录。跨节点重连风暴、慢消费者、真实锁等待/死锁、磁盘增长已经加入定向回归入口;目标拓扑的全局持久吞吐、锁等待/死锁告警阈值和生产采集/通知验证仍需环境验收。
|
2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定任务/交付/流程/Inbox状态计数、最近事件类别、终态耗时样本、固定事件阶段耗时、最早年龄、截断/新鲜度指标、提交前 MySQL 事务重试/耗尽计数和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签;发布前只读结构核验由[`orbit-admin verify`](api/operations.md)提供,运维人员也可用 `orbit-admin observe snapshot` 获取同一有界单节点快照;后台循环间隔、批量上限、Worker/Event连接上限、Hub领取协程和有界内存排队均支持有界环境配置,并在启动日志和固定指标中记录。跨节点重连风暴、慢消费者、真实锁等待/死锁、磁盘增长已经加入定向回归入口;目标拓扑的全局持久吞吐、锁等待/死锁告警阈值和生产采集/通知验证仍需环境验收。
|
||||||
3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、提交不明故障切点、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,旧 Worker 会话被排空且立即过期,撤销允许在停用范围内执行且不改写历史记录。已用一次性 MySQL 定向验证旧令牌失效、新令牌重绑和零外键;仍需多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。
|
3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、提交不明故障切点、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,旧 Worker 会话被排空且立即过期,撤销允许在停用范围内执行且不改写历史记录。已用一次性 MySQL 定向验证旧令牌失效、新令牌重绑和零外键;仍需多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。
|
||||||
4. 已补[数据库保留策略、预览和只读归档导出](api/retention.md)以及[`orbit-admin retention inventory`](api/retention.md)只读盘点。策略按命名空间存入 MySQL,控制台单管理员可修改,版本、原因和前后快照进入审计;默认关闭归档,源数据不会因配置变化自动删除。固定候选目录保护有效幂等身份、未确认交付、未知效果、未终结流程和未处理 Inbox;导出包带冻结策略版本和 SHA-256。仍需在目标环境评审最终归档介质、备份恢复、删除执行器和保留周期,再执行兼容升级和容量负载。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
|
4. 已补[数据库保留策略、预览和只读归档导出](api/retention.md)以及[`orbit-admin retention inventory`](api/retention.md)只读盘点。策略按命名空间存入 MySQL,控制台单管理员可修改,版本、原因和前后快照进入审计;默认关闭归档,源数据不会因配置变化自动删除。固定候选目录保护有效幂等身份、未确认交付、未知效果、未终结流程和未处理 Inbox;导出包带冻结策略版本和 SHA-256;控制台支持连续批次、累计数量、摘要校验和本批重下载,真实 MySQL 回归核对同时间戳分页、保护对象、版本及权限边界。仍需在目标环境评审最终归档介质、备份恢复、删除执行器和保留周期,再执行兼容升级和容量负载。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
|
||||||
5. 已补[`orbit-load`](api/load.md)有界受理负载工具及24次/1000次隔离样本,见[受理报告](reports/phase-6-load.md)。新增独立[`orbit-exec-load`](api/execution-load.md),通过真实Worker、任务结果和尝试历史核对,提供短/混合/中长任务的受理、排队、授权执行及完成耗时分布;具体环境、原始JSON和失败分母见[执行测量报告](reports/phase-6-execution-load.md)。新增[`orbit-workflow-load`](api/workflow-load.md),用本次发布版本的双分支并行流程,逐个核对不同任务、唯一尝试、来源、精确输出与本地调用;24/100实例及24实例截止负例已重新构建回归并绑定源码/二进制哈希,见[流程测量报告](reports/phase-6-workflow-load.md)。新增[`orbit-schedule-load`](api/schedule-load.md),已完成24/128次定时集中触发与24次计划量的截止负例,保存实际分母、唯一任务/尝试、来源与暂停收尾证据,见[定时测量报告](reports/phase-6-schedule-load.md)。Go 1.27.1 模块级门禁、MySQL 存储集成、关键 SDK/Orbit 进程故障组、24个可运行 SDK 示例真实服务串跑和 result-inbox/task-outbox 真实业务集成已复跑通过。已补[长期积压与资源曲线样本](reports/phase-6-backlog.md):32×8 共256个触发点,在1个Worker/容量1和180秒截止下保留173个成功、83个未核验任务,并以独立数据库读取记录等待积压曲线;该单节点样本不替代目标拓扑容量。SDK受控重连风暴及[真实慢消费者背压](reports/phase-6-slow-consumer.md)已补边界回归,积压脚本已加入 CPU/RSS、MySQL 资源、连接池等待、业务快照和事务重试计数采样;下一步补长期存储增长、跨节点重连与目标数据库拓扑容量。
|
5. 已补[`orbit-load`](api/load.md)有界受理负载工具及24次/1000次隔离样本,见[受理报告](reports/phase-6-load.md)。新增独立[`orbit-exec-load`](api/execution-load.md),通过真实Worker、任务结果和尝试历史核对,提供短/混合/中长任务的受理、排队、授权执行及完成耗时分布;具体环境、原始JSON和失败分母见[执行测量报告](reports/phase-6-execution-load.md)。新增[`orbit-workflow-load`](api/workflow-load.md),用本次发布版本的双分支并行流程,逐个核对不同任务、唯一尝试、来源、精确输出与本地调用;24/100实例及24实例截止负例已重新构建回归并绑定源码/二进制哈希,见[流程测量报告](reports/phase-6-workflow-load.md)。新增[`orbit-schedule-load`](api/schedule-load.md),已完成24/128次定时集中触发与24次计划量的截止负例,保存实际分母、唯一任务/尝试、来源与暂停收尾证据,见[定时测量报告](reports/phase-6-schedule-load.md)。Go 1.27.1 模块级门禁、MySQL 存储集成、关键 SDK/Orbit 进程故障组、24个可运行 SDK 示例真实服务串跑和 result-inbox/task-outbox 真实业务集成已复跑通过。已补[长期积压与资源曲线样本](reports/phase-6-backlog.md):32×8 共256个触发点,在1个Worker/容量1和180秒截止下保留173个成功、83个未核验任务,并以独立数据库读取记录等待积压曲线;该单节点样本不替代目标拓扑容量。SDK受控重连风暴及[真实慢消费者背压](reports/phase-6-slow-consumer.md)已补边界回归,积压脚本已加入 CPU/RSS、MySQL 资源、连接池等待、业务快照和事务重试计数采样;已补跨节点重连、慢消费者与有界磁盘增长样本(见 [P1 定向报告](reports/phase-6-stability-p1.md));长期磁盘曲线与目标数据库拓扑容量继续按实际负载验收。
|
||||||
6. 已补[隔离备份恢复演练](api/backup-restore.md):源库和恢复库均为一次性 MySQL 容器,Go 1.27.1 构建的当前二进制在逻辑备份恢复后核对迁移账本、核心记录数量、固定索引和零外键;证据见[恢复报告](reports/phase-6-backup-restore.md)。该演练不替代生产备份介质、复制切换、跨版本恢复或 RPO/RTO 验收。
|
6. 已补[隔离备份恢复演练](api/backup-restore.md):源库和恢复库均为一次性 MySQL 容器,Go 1.27.1 构建的当前二进制在逻辑备份恢复后核对迁移账本、核心记录数量、固定索引和零外键;证据见[恢复报告](reports/phase-6-backup-restore.md)。该演练不替代生产备份介质、复制切换、跨版本恢复或 RPO/RTO 验收。
|
||||||
7. 已补[A18 数据库短暂不可用恢复演练](api/db-recovery.md):Go 1.27.1 构建的当前二进制在数据库停止期间返回 readiness 503,同卷恢复后返回 200,并用原权限范围继续受理任务;证据见[数据库恢复报告](reports/phase-6-db-recovery.md)。该演练不替代主从切换、复制确认、跨故障域或 RPO/RTO 验收。
|
7. 已补[A18 数据库短暂不可用恢复演练](api/db-recovery.md):Go 1.27.1 构建的当前二进制在数据库停止期间返回 readiness 503,同卷恢复后返回 200,并用原权限范围继续受理任务;证据见[数据库恢复报告](reports/phase-6-db-recovery.md)。该演练不替代主从切换、复制确认、跨故障域或 RPO/RTO 验收。
|
||||||
8. 已补[MySQL GTID 复制与手工提升演练](reports/phase-6-mysql-replication.md):隔离源库/副本通过真实 Orbit HTTP 受理任务,源库停止后副本提升、结构核验和原幂等键重放通过;自动故障切换、复制延迟上限、目标拓扑及 RPO/RTO 仍待验收。
|
8. 已补[MySQL GTID 复制与手工提升演练](reports/phase-6-mysql-replication.md):隔离源库/副本通过真实 Orbit HTTP 受理任务,源库停止后副本提升、结构核验和原幂等键重放通过;自动故障切换、复制延迟上限、目标拓扑及 RPO/RTO 仍待验收。
|
||||||
@@ -243,4 +243,4 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
|||||||
|
|
||||||
### P4/P5 阶段结论与下一批
|
### P4/P5 阶段结论与下一批
|
||||||
|
|
||||||
[逐项证据与生产边界](reports/phase-4-5.md)已落表,P4/P5平台功能阶段验收通过。W06/W09/W14/W15真实竞争和全停、A04未送达、W01/W13提交断线及W16旧结果重放竞争均有对应回归,不重复列为待开发。下一批首先补运行指标与告警依据,再按上方P6顺序推进凭据轮换、保留/归档、容量、备份恢复与生产数据库故障验证;P7负责剩余场景和版本发布。完整首版目标保持进行中。
|
[逐项证据与生产边界](reports/phase-4-5.md)已落表,P4/P5平台功能阶段验收通过。W06/W09/W14/W15真实竞争和全停、A04未送达、W01/W13提交断线及W16旧结果重放竞争均有对应回归,不重复列为待开发。运行指标、凭据轮换、保留策略及只读归档、隔离容量样本和备份恢复均已按上方 P6 留有验证记录;后续重点是归档介质与删除执行器、长期磁盘增长、目标生产拓扑的容量/数据库故障及 RPO/RTO 验收。P7 负责剩余场景和版本发布。完整首版目标保持进行中。
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ ORBIT_RETENTION_INBOX_DAYS=90
|
|||||||
ORBIT_RETENTION_AUDIT_DAYS=2555
|
ORBIT_RETENTION_AUDIT_DAYS=2555
|
||||||
```
|
```
|
||||||
|
|
||||||
窗口范围为 1–3650 天,幂等记录不得短于 90 天;所有节点应使用同一组默认值。数据库中已有策略后,环境变量不覆盖它。管理端的“启用归档”只是允许归档执行器读取固定版本,不能绕过版本核对。
|
窗口范围为 1–3650 天,幂等记录不得短于 90 天;所有节点应使用同一组默认值。数据库中已有策略后,环境变量不覆盖它。管理端的“允许导出只读归档”控制导出权限,导出仍须核对固定策略版本。
|
||||||
|
|
||||||
归档分两步:先用“预览”或 `GET /console/retention/preview` 查看各类候选,再按固定类别调用 `POST /console/retention/export` 分批生成只读 JSON 快照。快照包含冻结的策略版本、数据库时间、截止时间、根记录及关联历史,并返回 SHA-256;源表仍然保留,导出成功只记录摘要和数量审计,不代表可以删除。后续删除执行器必须在独立发布和备份恢复验收后接入,不能把导出接口当成删除接口。
|
归档分两步:先用“预览”或 `GET /console/retention/preview` 查看各类候选,再按固定类别调用 `POST /console/retention/export` 分批生成只读 JSON 快照。每个快照包含冻结的策略版本、数据库时间、截止时间、根记录及关联历史,并返回 SHA-256;源表仍然保留,导出成功只记录摘要和数量审计,不代表可以删除。后续删除执行器必须在独立发布和备份恢复验收后接入,不能把导出接口当成删除接口。
|
||||||
|
|
||||||
候选筛选由服务端固定目录实现,浏览器不能提交表名或 SQL。终态任务必须没有未知效果、租约和流程所有权;流程必须终态且没有未结任务或未处理 Inbox;事件必须没有未确认交付或未处理 Inbox;交付只允许已确认记录;Inbox 只允许已处理且流程已终态记录;幂等记录需同时超过过期时间和窗口。审计单独使用审计窗口,普通清理不处理审计记录。未确认交付、未处理 Inbox、未知效果、进行中的任务/流程和有效幂等身份始终受保护。
|
候选筛选由服务端固定目录实现,浏览器不能提交表名或 SQL。终态任务必须没有未知效果、租约和流程所有权;流程必须终态且没有未结任务或未处理 Inbox;事件必须没有未确认交付或未处理 Inbox;交付只允许已确认记录;Inbox 只允许已处理且流程已终态记录;幂等记录需同时超过过期时间和窗口。审计单独使用审计窗口,普通清理不处理审计记录。未确认交付、未处理 Inbox、未知效果、进行中的任务/流程和有效幂等身份始终受保护。
|
||||||
|
|
||||||
@@ -32,6 +32,83 @@ curl -b orbit-console.cookie -X PUT http://127.0.0.1:8080/console/retention \
|
|||||||
--data '{"expected_revision":"0","enabled":false,"idempotency_days":90,"task_history_days":365,"workflow_history_days":365,"event_log_days":365,"delivery_history_days":90,"inbox_history_days":90,"audit_log_days":2555,"reason":"初始生产保留评审"}'
|
--data '{"expected_revision":"0","enabled":false,"idempotency_days":90,"task_history_days":365,"workflow_history_days":365,"event_log_days":365,"delivery_history_days":90,"inbox_history_days":90,"audit_log_days":2555,"reason":"初始生产保留评审"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 管理页操作
|
||||||
|
|
||||||
|
1. 登录后打开 `/console/#/retention`,调整窗口、填写单行原因后保存;原因最多 1024 UTF-8 字节。幂等记录最少 90 天。
|
||||||
|
2. 点击“预览候选”:每类最多展示 100 条,`100+` 表示还有更多,并非全库总数。修改未保存时不能预览或导出,避免使用错误版本。
|
||||||
|
3. 需要导出时开启“允许导出只读归档”并保存,选择历史类型,点击“导出首批”。每批最多 20 条根记录,关联历史一并导出。浏览器需要 HTTPS 或 localhost 安全上下文来执行 SHA-256 校验。
|
||||||
|
4. 页面显示本批、累计条数和完整摘要;有后续记录时显示“导出下一批”,到末批后停止。可“重新下载本批”,切换历史类型或保存新策略会重置进度。“从首批开始”重新扫描,不会清理已下载文件。
|
||||||
|
|
||||||
|
下载文件保存 `format_version`、`sha256`、原始字符串 `content` 三个字段。页面关闭或刷新后内存中的分页进度会丢失;文件中的 `content.next_cursor` 可用于接口继续导出。服务端提交后网络中断可能多记录一次导出审计,但不改变任何源业务记录;页面未成功校验时不推进游标。修改策略的网络重试在当前页面复用原幂等键。
|
||||||
|
|
||||||
|
## 导出接口与分页
|
||||||
|
|
||||||
|
这些接口仅允许已登录控制台会话;业务 Bearer 凭据不能代替管理员会话。
|
||||||
|
|
||||||
|
| 接口 | 请求/结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /console/retention` | `data` 为当前策略,`revision` 是字符串;未保存版本为 `"0"` |
|
||||||
|
| `PUT /console/retention` | 必填全部窗口、`enabled`、字符串 `expected_revision`、单行 `reason`,请求头必填 `Idempotency-Key`;幂等返回同一版本 |
|
||||||
|
| `GET /console/retention/preview` | `data.policy` 为所用策略,`sample_limit=100`;各类别包含 `kind/cutoff/count/more` |
|
||||||
|
| `POST /console/retention/export` | `kind`、字符串 `expected_revision` 必填;`limit` 默认 20、最大 100;`cursor` 默认空 |
|
||||||
|
|
||||||
|
响应采用标准 `{"data": ...}` 信封。导出请求示例:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -b orbit-console.cookie http://127.0.0.1:8080/console/retention/export \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"kind":"tasks","expected_revision":"1","limit":20}' \
|
||||||
|
--output export-response.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`data.content` 是原始 JSON 文本,解析后包括:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `policy` / `kind` | 本批实际策略与根记录类别 |
|
||||||
|
| `snapshot_at` / `cutoff` | 数据库读取时间与严格早于的候选截止时间,UTC |
|
||||||
|
| `source_retained` | 始终为 `true`,源数据保留 |
|
||||||
|
| `root_count` | 本批根记录数量,不含关联历史计数 |
|
||||||
|
| `tables` | 固定目录下的表与行;二进制为十六进制,整数和 JSON 正文按文本保留精度 |
|
||||||
|
| `next_cursor` | 非空表示可以继续;下一次请求原样传入相同 `kind` 和 `expected_revision` |
|
||||||
|
|
||||||
|
游标绑定命名空间、历史类型、策略版本与截止时间;不接受客户端表名或 SQL。策略变化后必须重新核对并从首批开始。每批使用独立数据库一致性快照,多个批次不是同一个全库时间点;期间从受保护状态转为候选的记录可能需要重新扫描。归档不是灾难恢复备份,不能直接把这些 JSON 导入生产库。
|
||||||
|
|
||||||
|
每个导出事务最多 10 秒、10000 条关联记录、8 MiB 正文;超限整体失败,不返回截断快照。减少根记录 `limit` 后可重新尝试;单个根记录本身过大时需要另行处理,不能忽略其历史。每次成功导出记录类别、策略版本、数量和摘要审计。
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 未登录/会话已失效 | 401,重新登录 |
|
||||||
|
| 归档关闭或策略版本不匹配 | 409,读取策略并重新核对 |
|
||||||
|
| 不支持的类别、游标不匹配、数量越界、快照超限 | 400,修正参数;超限时减小批量 |
|
||||||
|
| 修改策略结果不明 | 使用完全相同的内容和 `Idempotency-Key` 重试 |
|
||||||
|
|
||||||
|
离线核验及生成下一批请求(兼容控制台下载信封和上述 curl 响应):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 控制台下载文件也可替换这里的文件路径。
|
||||||
|
response = json.loads(Path("export-response.json").read_text(encoding="utf-8"))
|
||||||
|
snapshot = response.get("data", response)
|
||||||
|
assert snapshot["format_version"] == 1
|
||||||
|
content = snapshot["content"].encode("utf-8")
|
||||||
|
assert hashlib.sha256(content).hexdigest() == snapshot["sha256"], "快照校验失败"
|
||||||
|
page = json.loads(content)
|
||||||
|
assert page["source_retained"] is True
|
||||||
|
print("本批根记录:", page["root_count"])
|
||||||
|
if page["next_cursor"]:
|
||||||
|
Path("next-request.json").write_text(json.dumps({
|
||||||
|
"kind": page["kind"], "expected_revision": page["policy"]["revision"],
|
||||||
|
"limit": 20, "cursor": page["next_cursor"],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
# 下一批:curl ... --data-binary @next-request.json --output export-response-2.json
|
||||||
|
```
|
||||||
|
|
||||||
|
摘要针对 `content` 的原始 UTF-8 字节,不对解析后重新格式化的 JSON 计算。SHA-256 用于发现内容损坏;摘要与正文一起被篡改时无法证明真实性,需要把审计摘要或外部签名另行保存在可信介质中。
|
||||||
|
|
||||||
## 只读盘点
|
## 只读盘点
|
||||||
|
|
||||||
`orbit-admin retention inventory` 用于在制定保留窗口、归档和备份恢复方案前盘点历史数据。它只执行带截止时间的统计查询,不删除、搬迁、改写数据,也不会自动判断某条记录可以安全归档。
|
`orbit-admin retention inventory` 用于在制定保留窗口、归档和备份恢复方案前盘点历史数据。它只执行带截止时间的统计查询,不删除、搬迁、改写数据,也不会自动判断某条记录可以安全归档。
|
||||||
|
|||||||
@@ -19,3 +19,31 @@ make stability-check
|
|||||||
- `TestIntegrationRetentionDiskGrowthAndPreview`:写入带终态时间的有界任务样本,读取 `information_schema.TABLES` 的数据/索引字节增长,并核对保留策略预览只返回安全候选。
|
- `TestIntegrationRetentionDiskGrowthAndPreview`:写入带终态时间的有界任务样本,读取 `information_schema.TABLES` 的数据/索引字节增长,并核对保留策略预览只返回安全候选。
|
||||||
|
|
||||||
保留策略由数据库按命名空间保存,默认关闭归档。控制台单管理员通过“保留策略”页面修改窗口,要求版本、幂等键和原因;审计记录保留前后快照。导出是固定表目录的只读 JSON 快照,带 SHA-256 和策略版本,源数据仍保留。未确认交付、未知效果、未结束任务/流程、未处理 Inbox 和有效幂等记录不会成为普通归档候选。最终删除执行器、归档介质和目标拓扑 RPO/RTO 需要单独验收。
|
保留策略由数据库按命名空间保存,默认关闭归档。控制台单管理员通过“保留策略”页面修改窗口,要求版本、幂等键和原因;审计记录保留前后快照。导出是固定表目录的只读 JSON 快照,带 SHA-256 和策略版本,源数据仍保留。未确认交付、未知效果、未结束任务/流程、未处理 Inbox 和有效幂等记录不会成为普通归档候选。最终删除执行器、归档介质和目标拓扑 RPO/RTO 需要单独验收。
|
||||||
|
|
||||||
|
## 本地实测记录(2026-09-23)
|
||||||
|
|
||||||
|
环境为 macOS / arm64、Go 1.27.1、Docker MySQL 8.4.10。以下是隔离测试结果,不是百万企业容量或跨故障域高可用承诺。
|
||||||
|
|
||||||
|
上一批实现 `2f95d9c` 完成 `make test`、`make vet`、`make build`、`make docs-check` 与完整 `make integration`,包括独立 SDK 示例、真实进程故障及升级检查:
|
||||||
|
|
||||||
|
| 场景 | 观察结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 跨节点重连 | 两个独立进程间发生 4 次重连,业务处理 1 次,持久交付尝试 1 次 |
|
||||||
|
| 慢消费者 | 节点 A 消费阻塞期间,节点 B 仍完成独立交付 |
|
||||||
|
| 提交前死锁 | 真实反向加锁产生死锁,观察到 1 次有界重试后恢复 |
|
||||||
|
| 锁等待隔离 | 12 个阻塞连接参与应用公平派发验证,其他应用继续推进 |
|
||||||
|
| 新装与历史升级 | Schema 30;外键数量 0;v7 升级保留原 323 个索引组成项 |
|
||||||
|
| 有界磁盘增长 | 256 × 32 KiB 样本,表和索引统计从 1,818,624 增至 17,661,952 字节;候选预览 100 条、`more=true` |
|
||||||
|
|
||||||
|
本次继续补齐控制台归档分页和失败边界,在一次性数据库上运行 `go test -race -count=1 -run TestIntegrationRetention -v ./internal/storage/mysql`:
|
||||||
|
|
||||||
|
- `TestIntegrationRetentionPolicyIsSharedAuditedAndIdempotent`:不同 store 读取相同策略;相同幂等操作重放不增加修订号或重复写审计;旧版本修改被拒绝。
|
||||||
|
- `TestIntegrationRetentionDiskGrowthAndPreview`:256 条样本中 254 条终态,另 2 条保留为未知效果/运行中租约;终态记录采用相同完成时间验证复合游标。第一批 1 条,后续每批最多 37 条,共 8 批得到 254 条唯一根记录,源表仍为 256 条。
|
||||||
|
- 每批校验 SHA-256、策略版本及相同截止时间;跨类别/命名空间游标、无效游标、越界批量、业务 Bearer、旧策略和关闭策略均被拒绝。
|
||||||
|
- 此次物理统计增量为 12,238,848 字节(28,459,008 → 40,697,856)。该一次性数据库包含前面修正测试夹具时留下的样本,不能与上一轮绝对值直接比较;这里只验证有界写入增长及预览/导出的行为,不推导长期容量。
|
||||||
|
|
||||||
|
`make console-e2e` 在另一个空库从迁移与管理员初始化开始,验证:6 个已成功任务可见、WS Worker 排空、失败任务第 2 次成功、定时任务来源固定;保留策略保存/原键重放、旧版本 409、7 类预览、空快照摘要、退出后任务及策略接口 401 均通过。
|
||||||
|
|
||||||
|
本次 `make test`、`make vet`、`make build`、`make docs-check` 通过。服务端归档实现和迁移本次未修改,新增用例使用真实 MySQL 补充已有完整集成结果。
|
||||||
|
|
||||||
|
浏览器使用同一批隔离样本验证真实页面:关闭导出时仍可预览;存在未保存修改时禁用预览和导出;开启并保存后版本从 3 升至 4;首批 20 条、第二批累计 40 条且均通过浏览器 SHA-256 校验;切换类别重置进度并清除旧下载链接;空流程快照显示 0 条/已到末批且禁用继续导出。页面错误日志为空。开关点击区域和焦点提示已修正,截止时间带年份和 UTC,源数据不会被页面操作删除。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
@@ -168,11 +169,19 @@ func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ids = append(ids, string(out.Task.ID))
|
ids = append(ids, string(out.Task.ID))
|
||||||
}
|
}
|
||||||
|
completedAt := time.Now().UTC().Add(-48 * time.Hour).Truncate(time.Microsecond)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=UTC_TIMESTAMP(6)-INTERVAL 2 DAY,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, boot.NamespaceID, id); err != nil {
|
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, completedAt, boot.NamespaceID, id); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 同一完成时间验证复合游标;未知效果和活跃租约不能因时间足够旧而被导出。
|
||||||
|
if _, err := db.ExecContext(ctx, "UPDATE pulse_tasks SET state='NEEDS_REVIEW',completed_at=NULL,effect_uncertain=TRUE WHERE namespace_id=? AND id=UNHEX(?)", boot.NamespaceID, ids[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, "UPDATE pulse_tasks SET state='RUNNING',completed_at=NULL,current_attempt_id=UNHEX('00000000000000000000000000000001'),lease_until=UTC_TIMESTAMP(6)+INTERVAL 1 HOUR WHERE namespace_id=? AND id=UNHEX(?)", boot.NamespaceID, ids[1]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil {
|
if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -213,7 +222,103 @@ func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) {
|
|||||||
if snapshot.SHA256 != hex.EncodeToString(sum[:]) || !json.Valid([]byte(snapshot.Content)) || !strings.Contains(snapshot.Content, `"source_retained":true`) {
|
if snapshot.SHA256 != hex.EncodeToString(sum[:]) || !json.Valid([]byte(snapshot.Content)) || !strings.Contains(snapshot.Content, `"source_retained":true`) {
|
||||||
t.Fatalf("retention snapshot integrity/source marker failed: sha=%s content=%s", snapshot.SHA256, snapshot.Content[:min(len(snapshot.Content), 200)])
|
t.Fatalf("retention snapshot integrity/source marker failed: sha=%s content=%s", snapshot.SHA256, snapshot.Content[:min(len(snapshot.Content), 200)])
|
||||||
}
|
}
|
||||||
t.Logf("bounded retention growth sample inserted %d terminal tasks; bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More)
|
var first archiveData
|
||||||
|
if err := json.Unmarshal([]byte(snapshot.Content), &first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.NextCursor == "" {
|
||||||
|
t.Fatal("first page lost continuation cursor")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
page := first
|
||||||
|
batches := 0
|
||||||
|
for {
|
||||||
|
batches++
|
||||||
|
if page.Cutoff != first.Cutoff || page.Policy.Revision != 1 || !page.SourceRetained {
|
||||||
|
t.Fatalf("batch changed boundary: %+v", page.Policy)
|
||||||
|
}
|
||||||
|
if page.RootCount != len(page.Tables["pulse_tasks"]) {
|
||||||
|
t.Fatal("root count mismatch")
|
||||||
|
}
|
||||||
|
for _, row := range page.Tables["pulse_tasks"] {
|
||||||
|
id, ok := row["id"].(string)
|
||||||
|
if !ok || seen[id] || id == ids[0] || id == ids[1] {
|
||||||
|
t.Fatalf("duplicate/unsafe root: %v", row["id"])
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
if page.NextCursor == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if batches > 10 {
|
||||||
|
t.Fatal("cursor did not make bounded progress")
|
||||||
|
}
|
||||||
|
next, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", Limit: 37, Cursor: page.NextCursor, ExpectedRevision: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte(next.Content))
|
||||||
|
if next.SHA256 != hex.EncodeToString(digest[:]) {
|
||||||
|
t.Fatal("batch digest mismatch")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(next.Content), &page); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) != len(ids)-2 {
|
||||||
|
t.Fatalf("exported %d roots; want %d", len(seen), len(ids)-2)
|
||||||
|
}
|
||||||
|
var retained int
|
||||||
|
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=?", boot.NamespaceID).Scan(&retained); err != nil || retained != len(ids) {
|
||||||
|
t.Fatalf("source data changed: count=%d err=%v", retained, err)
|
||||||
|
}
|
||||||
|
for _, in := range []retention.ArchiveInput{
|
||||||
|
{Kind: "audit", Cursor: first.NextCursor, ExpectedRevision: 1},
|
||||||
|
{Kind: "tasks", Cursor: "not-a-cursor", ExpectedRevision: 1},
|
||||||
|
{Kind: "tasks", Limit: 101, ExpectedRevision: 1},
|
||||||
|
} {
|
||||||
|
if _, err := store.ExportRetention(ctx, consoleToken, in); err == nil {
|
||||||
|
t.Fatalf("invalid export accepted: %+v", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(first.NextCursor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var foreign archiveCursor
|
||||||
|
if err := json.Unmarshal(raw, &foreign); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
foreign.Namespace = testID(t)
|
||||||
|
raw, err = json.Marshal(foreign)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1, Cursor: base64.RawURLEncoding.EncodeToString(raw)}); err == nil {
|
||||||
|
t.Fatal("foreign namespace cursor accepted")
|
||||||
|
}
|
||||||
|
if _, err := store.ExportRetention(ctx, apiToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1}); err == nil {
|
||||||
|
t.Fatal("business bearer token accepted as console admin")
|
||||||
|
}
|
||||||
|
policyChange := retention.UpdateInput{ExpectedRevision: 1, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 2, WorkflowHistoryDays: 30, EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30, AuditLogDays: 365, Reason: "verify frozen cursor boundary"}
|
||||||
|
if _, err := store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-revise", policyChange); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1, Cursor: first.NextCursor}); faultCode(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatalf("stale policy allowed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 2, Cursor: first.NextCursor}); err == nil {
|
||||||
|
t.Fatal("old cursor accepted with new policy")
|
||||||
|
}
|
||||||
|
policyChange.ExpectedRevision, policyChange.Enabled = 2, false
|
||||||
|
if _, err := store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-disable", policyChange); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 3}); faultCode(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatalf("disabled policy allowed: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("archive pagination: batches=%d unique_roots=%d protected_roots=2 source_rows=%d; digest, namespace, kind, revision, disabled policy and console-only checks passed", batches, len(seen), retained)
|
||||||
|
t.Logf("bounded retention growth sample inserted %d tasks (254 terminal, 2 protected); bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More)
|
||||||
}
|
}
|
||||||
|
|
||||||
func retentionTableBytes(t *testing.T, ctx context.Context, db interface {
|
func retentionTableBytes(t *testing.T, ctx context.Context, db interface {
|
||||||
|
|||||||
@@ -63,9 +63,29 @@
|
|||||||
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
|
||||||
|
function syncRetentionControls() {
|
||||||
|
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
|
||||||
|
$('retention-preview-button').disabled = retentionUI.busy || retentionUI.dirty || !state.retention;
|
||||||
|
$('retention-export-button').disabled = retentionUI.busy || retentionUI.dirty || !state.retention?.enabled || retentionUI.done;
|
||||||
|
$('retention-export-button').textContent = retentionUI.cursor ? '导出下一批 ↓' : '导出首批 ↓';
|
||||||
|
$('retention-restart-button').disabled = retentionUI.busy || retentionUI.batch === 0;
|
||||||
|
text('retention-export-hint', retentionUI.dirty ? '有未保存的修改,请先保存策略后再预览或导出。' : !state.retention?.enabled ? '可预览候选;导出前请开启只读归档并保存策略。' : '每批最多 20 条根记录及关联历史;生成后可下载下一批。源数据始终保留。');
|
||||||
|
}
|
||||||
|
function resetRetentionExport() {
|
||||||
|
if (retentionUI.download) URL.revokeObjectURL(retentionUI.download);
|
||||||
|
Object.assign(retentionUI, { cursor: '', batch: 0, count: 0, done: false, download: '' });
|
||||||
|
$('retention-download').hidden = true;
|
||||||
|
$('retention-download').removeAttribute('href');
|
||||||
|
text('retention-export-status', '尚未导出');
|
||||||
|
text('retention-export-sha', '');
|
||||||
|
syncRetentionControls();
|
||||||
|
}
|
||||||
function renderRetention(policy) {
|
function renderRetention(policy) {
|
||||||
if (!policy) return;
|
if (!policy) return;
|
||||||
|
const changed = !state.retention || String(state.retention.revision) !== String(policy.revision);
|
||||||
state.retention = policy;
|
state.retention = policy;
|
||||||
|
retentionUI.dirty = false;
|
||||||
$('retention-enabled').checked = Boolean(policy.enabled);
|
$('retention-enabled').checked = Boolean(policy.enabled);
|
||||||
$('retention-idempotency').value = policy.idempotency_days ?? '';
|
$('retention-idempotency').value = policy.idempotency_days ?? '';
|
||||||
$('retention-task').value = policy.task_history_days ?? '';
|
$('retention-task').value = policy.task_history_days ?? '';
|
||||||
@@ -75,16 +95,23 @@
|
|||||||
$('retention-inbox').value = policy.inbox_history_days ?? '';
|
$('retention-inbox').value = policy.inbox_history_days ?? '';
|
||||||
$('retention-audit').value = policy.audit_log_days ?? '';
|
$('retention-audit').value = policy.audit_log_days ?? '';
|
||||||
text('retention-source', policy.source === 'database' ? '已保存配置' : '默认策略');
|
text('retention-source', policy.source === 'database' ? '已保存配置' : '默认策略');
|
||||||
text('retention-revision', policy.revision ? String(policy.revision) : '默认');
|
text('retention-revision', String(policy.revision || '0') === '0' ? '默认' : String(policy.revision));
|
||||||
text('retention-updated', policy.updated_at ? formatTime(policy.updated_at) : '尚未保存');
|
text('retention-updated', policy.source === 'database' ? formatTime(policy.updated_at) : '尚未保存');
|
||||||
|
if (changed) {
|
||||||
|
resetRetentionExport();
|
||||||
|
text('retention-preview', '点击预览,查看当前已保存策略下的候选样本。');
|
||||||
|
}
|
||||||
|
syncRetentionControls();
|
||||||
}
|
}
|
||||||
async function loadRetention() {
|
async function loadRetention() {
|
||||||
|
if (retentionUI.busy || retentionUI.dirty) return;
|
||||||
|
retentionUI.busy = true; syncRetentionControls();
|
||||||
try {
|
try {
|
||||||
const response = await request('/console/retention');
|
const response = await request('/console/retention');
|
||||||
renderRetention(extractData(response));
|
renderRetention(extractData(response));
|
||||||
} catch (error) {
|
text('retention-error', '');
|
||||||
$('retention-error').textContent = error.message || '保留策略读取失败';
|
} catch (error) { text('retention-error', error.message || '保留策略读取失败'); }
|
||||||
}
|
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||||
}
|
}
|
||||||
function retentionNumber(id) {
|
function retentionNumber(id) {
|
||||||
const value = Number($(id).value);
|
const value = Number($(id).value);
|
||||||
@@ -92,50 +119,72 @@
|
|||||||
}
|
}
|
||||||
async function submitRetention(event) {
|
async function submitRetention(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (retentionUI.busy || !state.retention) return;
|
||||||
const fields = { idempotency: 'retention-idempotency', task: 'retention-task', workflow: 'retention-workflow', event: 'retention-event', delivery: 'retention-delivery', inbox: 'retention-inbox', audit: 'retention-audit' };
|
const fields = { idempotency: 'retention-idempotency', task: 'retention-task', workflow: 'retention-workflow', event: 'retention-event', delivery: 'retention-delivery', inbox: 'retention-inbox', audit: 'retention-audit' };
|
||||||
const values = Object.fromEntries(Object.entries(fields).map(([key, id]) => [key, retentionNumber(id)]));
|
const values = Object.fromEntries(Object.entries(fields).map(([key, id]) => [key, retentionNumber(id)]));
|
||||||
if (Object.values(values).some(value => !value)) { $('retention-error').textContent = '所有保留天数必须是 1 到 3650 的整数。'; return; }
|
if (Object.values(values).some(value => !value) || values.idempotency < 90) { text('retention-error', '幂等记录至少保留 90 天;其他窗口为 1 到 3650 的整数。'); return; }
|
||||||
const reason = $('retention-reason').value.trim();
|
const reason = $('retention-reason').value.trim();
|
||||||
if (!reason) { $('retention-error').textContent = '请填写变更原因。'; return; }
|
if (!reason || /[\r\n]/.test(reason) || new TextEncoder().encode(reason).length > 1024) { text('retention-error', '请填写单行变更原因,最多 1024 字节(中文约 341 字)。'); return; }
|
||||||
const button = $('retention-submit'); button.disabled = true; $('retention-error').textContent = '';
|
const body = JSON.stringify({ expected_revision: String(state.retention.revision || 0), enabled: $('retention-enabled').checked, idempotency_days: values.idempotency, task_history_days: values.task, workflow_history_days: values.workflow, event_log_days: values.event, delivery_history_days: values.delivery, inbox_history_days: values.inbox, audit_log_days: values.audit, reason });
|
||||||
const current = state.retention || {};
|
// 网络响应不明时,原样重试必须复用同一个操作键。
|
||||||
|
if (retentionUI.pendingSave?.body !== body) retentionUI.pendingSave = { body, key: operationKey('retention-policy') };
|
||||||
|
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
|
||||||
try {
|
try {
|
||||||
const response = await request('/console/retention', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey('retention-policy') }, body: JSON.stringify({ expected_revision: String(current.revision || 0), enabled: $('retention-enabled').checked, idempotency_days: values.idempotency, task_history_days: values.task, workflow_history_days: values.workflow, event_log_days: values.event, delivery_history_days: values.delivery, inbox_history_days: values.inbox, audit_log_days: values.audit, reason }) });
|
const response = await request('/console/retention', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': retentionUI.pendingSave.key }, body });
|
||||||
renderRetention(extractData(response)); $('retention-reason').value = ''; showToast('保留策略已保存,已写入管理审计');
|
renderRetention(extractData(response)); retentionUI.pendingSave = null;
|
||||||
} catch (error) { $('retention-error').textContent = error.message || '保留策略保存失败'; }
|
$('retention-reason').value = ''; showToast('保留策略已保存,已写入管理审计');
|
||||||
finally { button.disabled = false; }
|
} catch (error) { text('retention-error', `${error.message || '保留策略保存失败'}。网络故障可直接重试;版本冲突请刷新页面后核对。`); }
|
||||||
|
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||||
|
}
|
||||||
|
function retentionCutoff(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }).format(date) + ' UTC';
|
||||||
}
|
}
|
||||||
function renderRetentionPreview(preview) {
|
function renderRetentionPreview(preview) {
|
||||||
const el = $('retention-preview');
|
const el = $('retention-preview');
|
||||||
const items = Array.isArray(preview?.items) ? preview.items : [];
|
const items = Array.isArray(preview?.items) ? preview.items : [];
|
||||||
if (!items.length) { el.innerHTML = '<span>当前策略没有可预览的候选数据。</span>'; return; }
|
if (!items.length) { el.textContent = '当前策略没有可预览的候选数据。'; return; }
|
||||||
const labels = { tasks: '任务历史', workflows: '流程历史', events: '事件日志', audit: '管理审计', deliveries: '交付历史', inbox: 'Inbox 历史', idempotency: '幂等记录' };
|
const labels = { tasks: '任务历史', workflows: '流程历史', events: '事件日志', audit: '管理审计', deliveries: '交付历史', inbox: 'Inbox 历史', idempotency: '幂等记录' };
|
||||||
el.innerHTML = `<div class="retention-preview-head"><b>候选预览</b><small>仅展示样本,不会删除数据</small></div>${items.map(item => `<div class="retention-preview-row"><span>${escapeHTML(labels[item.kind] || item.kind)}</span><b>${escapeHTML(String(item.count || 0))} 条</b><small>截止 ${escapeHTML(formatTime(item.cutoff))}${item.more ? ' · 还有更多' : ''}</small></div>`).join('')}`;
|
el.innerHTML = `<div class="retention-preview-head"><b>候选预览 · 版本 ${escapeHTML(String(preview.policy?.revision || '0'))}</b><small>每类最多 ${escapeHTML(String(preview.sample_limit))} 条样本,不是全库总数</small></div>${items.map(item => `<div class="retention-preview-row"><span>${escapeHTML(labels[item.kind] || item.kind)}</span><b>${escapeHTML(String(item.count || 0))}${item.more ? '+' : ''} 条</b><small>截止 ${escapeHTML(retentionCutoff(item.cutoff))}${item.more ? ' · 还有更多' : ''}</small></div>`).join('')}`;
|
||||||
}
|
}
|
||||||
async function previewRetention() {
|
async function previewRetention() {
|
||||||
const button = $('retention-preview-button'); button.disabled = true; $('retention-error').textContent = '';
|
if (retentionUI.busy || retentionUI.dirty) return;
|
||||||
|
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
|
||||||
try {
|
try {
|
||||||
const response = await request('/console/retention/preview');
|
const response = await request('/console/retention/preview');
|
||||||
renderRetentionPreview(extractData(response));
|
const preview = extractData(response);
|
||||||
} catch (error) { $('retention-error').textContent = error.message || '候选预览失败'; }
|
if (String(preview.policy?.revision) !== String(state.retention?.revision)) throw new Error('策略已被更新,请刷新页面核对后再预览');
|
||||||
finally { button.disabled = false; }
|
renderRetentionPreview(preview);
|
||||||
|
} catch (error) { text('retention-error', error.message || '候选预览失败'); }
|
||||||
|
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||||
}
|
}
|
||||||
async function exportRetention() {
|
async function exportRetention() {
|
||||||
const button = $('retention-export-button'); button.disabled = true; $('retention-error').textContent = '';
|
if (retentionUI.busy || retentionUI.dirty || !state.retention?.enabled || retentionUI.done) return;
|
||||||
|
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
|
||||||
try {
|
try {
|
||||||
|
if (!window.crypto?.subtle) throw new Error('完整性校验需要 HTTPS 或本机 localhost 地址');
|
||||||
const kind = $('retention-kind').value;
|
const kind = $('retention-kind').value;
|
||||||
const current = state.retention || {};
|
const revision = String(state.retention.revision || 0);
|
||||||
const response = await request('/console/retention/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, limit: 20, expected_revision: String(current.revision || 0) }) });
|
const response = await request('/console/retention/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, limit: 20, cursor: retentionUI.cursor, expected_revision: revision }) });
|
||||||
const snapshot = extractData(response);
|
const snapshot = extractData(response);
|
||||||
const content = String(snapshot.content || '');
|
if (snapshot.format_version !== 1 || typeof snapshot.content !== 'string' || !snapshot.content) throw new Error('服务端返回了无效的归档快照');
|
||||||
if (!content) throw new Error('服务端返回了空的归档快照');
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(snapshot.content));
|
||||||
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
|
const sha = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
|
||||||
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
|
if (sha !== snapshot.sha256) throw new Error('快照完整性校验失败,请重新导出本批次');
|
||||||
anchor.href = url; anchor.download = `orbit-retention-${kind}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; anchor.click();
|
const data = JSON.parse(snapshot.content);
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
if (data.kind !== kind || String(data.policy?.revision) !== revision || data.source_retained !== true || !Number.isSafeInteger(data.root_count) || data.root_count < 0 || data.root_count > 20 || (data.next_cursor != null && typeof data.next_cursor !== 'string')) throw new Error('快照与当前导出请求不一致');
|
||||||
showToast(`快照已下载 · SHA256 ${String(snapshot.sha256 || '').slice(0, 12)}…`);
|
// 下载信封同时保留原始正文和摘要,离线校验时不要重新序列化 content。
|
||||||
} catch (error) { $('retention-error').textContent = error.message || '归档快照导出失败'; }
|
const url = URL.createObjectURL(new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json;charset=utf-8' }));
|
||||||
finally { button.disabled = false; }
|
if (retentionUI.download) URL.revokeObjectURL(retentionUI.download);
|
||||||
|
Object.assign(retentionUI, { download: url, cursor: data.next_cursor || '', batch: retentionUI.batch + 1, count: retentionUI.count + data.root_count, done: !data.next_cursor });
|
||||||
|
const anchor = $('retention-download');
|
||||||
|
anchor.href = url; anchor.download = `orbit-retention-${kind}-r${revision}-batch${retentionUI.batch}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||||
|
anchor.hidden = false; anchor.click();
|
||||||
|
text('retention-export-status', `第 ${retentionUI.batch} 批 · 本批 ${data.root_count} 条 · 累计 ${retentionUI.count} 条根记录 · ${retentionUI.done ? '已到末批' : '可继续下一批'} · SHA-256 校验通过`);
|
||||||
|
text('retention-export-sha', sha);
|
||||||
|
showToast('快照已生成,源数据保留');
|
||||||
|
} catch (error) { text('retention-error', error.message || '归档快照导出失败'); }
|
||||||
|
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||||
}
|
}
|
||||||
function renderConnectionSummary() {
|
function renderConnectionSummary() {
|
||||||
text('summary-api', state.base || location.origin);
|
text('summary-api', state.base || location.origin);
|
||||||
@@ -548,6 +597,9 @@
|
|||||||
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
||||||
}
|
}
|
||||||
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
|
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
|
||||||
|
$('retention-kind').addEventListener('change', resetRetentionExport);
|
||||||
|
$('retention-restart-button').addEventListener('click', resetRetentionExport);
|
||||||
|
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
|
||||||
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
|
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
|
||||||
$('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 (!$('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(); } });
|
$('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 (!$('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);
|
$('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);
|
||||||
|
|||||||
@@ -134,11 +134,11 @@
|
|||||||
<section class="content-grid single-column">
|
<section class="content-grid single-column">
|
||||||
<article class="panel wide-panel retention-panel">
|
<article class="panel wide-panel retention-panel">
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
||||||
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;归档执行器还会核对终态、未确认结果、备份和审计要求。</p>
|
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。</p>
|
||||||
<form id="retention-form" class="retention-form">
|
<form id="retention-form" class="retention-form">
|
||||||
<div class="retention-switch"><div><b>允许归档执行器使用策略</b><small>默认关闭。打开后仍需独立归档任务执行,未确认任务、交付和 Inbox 不会按窗口清理。</small></div><label class="switch"><input id="retention-enabled" type="checkbox"><span></span></label></div>
|
<div class="retention-switch"><div><b>允许导出只读归档</b><small>默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。</small></div><label class="switch"><input id="retention-enabled" type="checkbox" aria-label="允许导出只读归档"><span></span></label></div>
|
||||||
<div class="retention-grid">
|
<div class="retention-grid">
|
||||||
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="1" max="3650" required></label>
|
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="90" max="3650" required></label>
|
||||||
<label class="retention-field"><span>任务历史</span><small>天</small><input id="retention-task" type="number" min="1" max="3650" required></label>
|
<label class="retention-field"><span>任务历史</span><small>天</small><input id="retention-task" type="number" min="1" max="3650" required></label>
|
||||||
<label class="retention-field"><span>流程历史</span><small>天</small><input id="retention-workflow" type="number" min="1" max="3650" required></label>
|
<label class="retention-field"><span>流程历史</span><small>天</small><input id="retention-workflow" type="number" min="1" max="3650" required></label>
|
||||||
<label class="retention-field"><span>事件日志</span><small>天</small><input id="retention-event" type="number" min="1" max="3650" required></label>
|
<label class="retention-field"><span>事件日志</span><small>天</small><input id="retention-event" type="number" min="1" max="3650" required></label>
|
||||||
@@ -148,12 +148,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="retention-meta"><span>当前版本 <b id="retention-revision">默认</b></span><span>最后修改 <b id="retention-updated">—</b></span></div>
|
<div class="retention-meta"><span>当前版本 <b id="retention-revision">默认</b></span><span>最后修改 <b id="retention-updated">—</b></span></div>
|
||||||
<label class="field-label" for="retention-reason">变更原因</label>
|
<label class="field-label" for="retention-reason">变更原因</label>
|
||||||
<textarea id="retention-reason" rows="3" maxlength="1024" placeholder="说明为什么调整历史保留窗口"></textarea>
|
<input id="retention-reason" class="retention-reason" type="text" maxlength="1024" placeholder="说明为什么调整历史保留窗口" required>
|
||||||
<div class="action-form-buttons"><button class="primary-button compact" id="retention-submit" type="submit">保存保留策略 <span>→</span></button></div>
|
<div class="action-form-buttons"><button class="primary-button compact" id="retention-submit" type="submit">保存保留策略 <span>→</span></button></div>
|
||||||
<div class="retention-tools">
|
<div class="retention-tools">
|
||||||
<label class="retention-select"><span>历史类型</span><select id="retention-kind"><option value="tasks">任务历史</option><option value="workflows">流程历史</option><option value="events">事件日志</option><option value="audit">管理审计</option><option value="deliveries">交付历史</option><option value="inbox">Inbox 历史</option><option value="idempotency">幂等记录</option></select></label>
|
<label class="retention-select"><span>历史类型</span><select id="retention-kind"><option value="tasks">任务历史</option><option value="workflows">流程历史</option><option value="events">事件日志</option><option value="audit">管理审计</option><option value="deliveries">交付历史</option><option value="inbox">Inbox 历史</option><option value="idempotency">幂等记录</option></select></label>
|
||||||
<div class="retention-tool-buttons"><button class="secondary-button compact" id="retention-preview-button" type="button">预览候选</button><button class="secondary-button compact" id="retention-export-button" type="button">导出只读快照 <span>↓</span></button></div>
|
<div class="retention-tool-buttons"><button class="secondary-button compact" id="retention-preview-button" type="button">预览候选</button><button class="secondary-button compact" id="retention-export-button" type="button">导出首批 ↓</button><button class="secondary-button compact" id="retention-restart-button" type="button" disabled>从首批开始</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="panel-description" id="retention-export-hint"></p>
|
||||||
|
<div class="retention-export-result" aria-live="polite"><span id="retention-export-status">尚未导出</span><a id="retention-download" hidden>重新下载本批</a><code id="retention-export-sha"></code></div>
|
||||||
<div class="retention-preview" id="retention-preview" aria-live="polite"><span>保存策略后可预览当前窗口下的候选数据;预览不会删除任何记录。</span></div>
|
<div class="retention-preview" id="retention-preview" aria-live="polite"><span>保存策略后可预览当前窗口下的候选数据;预览不会删除任何记录。</span></div>
|
||||||
<div class="action-error" id="retention-error" role="alert"></div>
|
<div class="action-error" id="retention-error" role="alert"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,5 +6,7 @@
|
|||||||
.detail-row-action{border:1px solid rgba(255,179,107,.28);border-radius:8px;padding:6px 9px;color:var(--orange);background:rgba(255,179,107,.07);font:10px inherit;cursor:pointer;white-space:nowrap}.detail-row-action:hover{border-color:rgba(255,179,107,.65);color:#ffe0bb;background:rgba(255,179,107,.13)}
|
.detail-row-action{border:1px solid rgba(255,179,107,.28);border-radius:8px;padding:6px 9px;color:var(--orange);background:rgba(255,179,107,.07);font:10px inherit;cursor:pointer;white-space:nowrap}.detail-row-action:hover{border-color:rgba(255,179,107,.65);color:#ffe0bb;background:rgba(255,179,107,.13)}
|
||||||
.recovery-block{border-color:rgba(255,179,107,.2);background:linear-gradient(135deg,rgba(255,179,107,.045),rgba(98,230,212,.025))}.recovery-intro{margin:0 0 12px;color:#8399b0;font-size:10px;line-height:1.6}.recovery-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.recovery-action-button{display:flex;flex-direction:column;align-items:flex-start;gap:4px;border:1px solid rgba(255,179,107,.2);border-radius:10px;padding:10px 11px;color:#e6f0ff;background:rgba(4,12,23,.4);text-align:left;cursor:pointer;transition:border-color .2s,background .2s,transform .2s}.recovery-action-button:hover,.recovery-action-button:focus{outline:0;border-color:rgba(98,230,212,.6);background:rgba(98,230,212,.08);transform:translateY(-1px)}.recovery-action-button b{font-size:10px;font-weight:650}.recovery-action-button small{color:#7890a8;font-size:9px;line-height:1.4}.recovery-form{border-color:rgba(255,179,107,.22)}.recovery-snapshot{margin:0 0 14px;padding:8px 10px;border-radius:8px;background:rgba(98,230,212,.06);color:#8db4b6;font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.recovery-inline-fields{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px}.recovery-inline-fields select{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px}.recovery-inline-fields select:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}@media(max-width:780px){.recovery-action-grid{grid-template-columns:1fr}.recovery-inline-fields{grid-template-columns:1fr}}
|
.recovery-block{border-color:rgba(255,179,107,.2);background:linear-gradient(135deg,rgba(255,179,107,.045),rgba(98,230,212,.025))}.recovery-intro{margin:0 0 12px;color:#8399b0;font-size:10px;line-height:1.6}.recovery-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.recovery-action-button{display:flex;flex-direction:column;align-items:flex-start;gap:4px;border:1px solid rgba(255,179,107,.2);border-radius:10px;padding:10px 11px;color:#e6f0ff;background:rgba(4,12,23,.4);text-align:left;cursor:pointer;transition:border-color .2s,background .2s,transform .2s}.recovery-action-button:hover,.recovery-action-button:focus{outline:0;border-color:rgba(98,230,212,.6);background:rgba(98,230,212,.08);transform:translateY(-1px)}.recovery-action-button b{font-size:10px;font-weight:650}.recovery-action-button small{color:#7890a8;font-size:9px;line-height:1.4}.recovery-form{border-color:rgba(255,179,107,.22)}.recovery-snapshot{margin:0 0 14px;padding:8px 10px;border-radius:8px;background:rgba(98,230,212,.06);color:#8db4b6;font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.recovery-inline-fields{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px}.recovery-inline-fields select{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px}.recovery-inline-fields select:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}@media(max-width:780px){.recovery-action-grid{grid-template-columns:1fr}.recovery-inline-fields{grid-template-columns:1fr}}
|
||||||
.overview-grid{grid-template-columns:minmax(0,1.45fr) minmax(300px,.7fr)}.stats-panel,.connection-summary-panel{min-height:326px}.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.stat-tile{padding:14px 13px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.stat-tile span{display:block;color:#7188a3;font-size:9px}.stat-tile strong{display:block;margin-top:10px;color:#e4f1fc;font-size:23px;letter-spacing:-.04em}.stat-tile small{display:block;margin-top:5px;color:#607791;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.distribution-block{margin-top:22px;padding-top:18px;border-top:1px solid rgba(156,183,218,.1)}.distribution-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;color:#afc2d6;font-size:11px}.distribution-heading small{color:#687f99;font-size:9px}.distribution-list{display:grid;gap:10px}.distribution-row{display:grid;grid-template-columns:42px minmax(0,1fr) 28px;gap:10px;align-items:center;color:#7187a1;font-size:9px}.distribution-row b{text-align:right;color:#c1d2e2;font-size:10px;font-weight:500}.status-track{height:5px;border-radius:5px;background:#1c3048;overflow:hidden}.status-track i{display:block;width:0;height:100%;border-radius:5px;transition:width .45s ease}.distribution-row:nth-child(1) .status-track i{background:var(--cyan)}.distribution-row:nth-child(2) .status-track i{background:var(--blue)}.distribution-row:nth-child(3) .status-track i{background:var(--purple)}.distribution-row:nth-child(4) .status-track i{background:var(--orange)}.connection-summary-copy{display:flex;align-items:center;gap:12px;padding:14px;border:1px solid rgba(98,230,212,.12);border-radius:14px;background:rgba(4,15,26,.3)}.connection-summary-icon{display:grid;place-items:center;width:33px;height:33px;border-radius:11px;background:rgba(98,230,212,.1);color:var(--cyan);font-size:17px}.connection-summary-copy b{display:block;color:#d5e5f4;font-size:12px;max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.connection-summary-copy small{display:block;margin-top:5px;color:#68809a;font-size:9px}.connection-summary-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;margin-top:12px;border:1px solid rgba(156,183,218,.1);border-radius:13px;overflow:hidden}.connection-summary-grid>div{padding:12px;background:rgba(4,13,24,.25)}.connection-summary-grid span{display:block;color:#647b96;font-size:9px;margin-bottom:5px}.connection-summary-grid b{display:block;color:#b9cde0;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.connection-summary-note{min-height:31px;margin:15px 0 14px;color:#6f86a0;font-size:10px;line-height:1.6}.connection-summary-panel>.quiet-button{width:100%;justify-content:center}.connection-settings-intro{margin:0 0 18px}.connection-drawer{width:min(510px,100%)}@media(max-width:1200px){.stat-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:780px){.overview-grid{grid-template-columns:1fr}.stat-grid{grid-template-columns:repeat(2,1fr)}.connection-drawer{width:100%}}
|
.overview-grid{grid-template-columns:minmax(0,1.45fr) minmax(300px,.7fr)}.stats-panel,.connection-summary-panel{min-height:326px}.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.stat-tile{padding:14px 13px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.stat-tile span{display:block;color:#7188a3;font-size:9px}.stat-tile strong{display:block;margin-top:10px;color:#e4f1fc;font-size:23px;letter-spacing:-.04em}.stat-tile small{display:block;margin-top:5px;color:#607791;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.distribution-block{margin-top:22px;padding-top:18px;border-top:1px solid rgba(156,183,218,.1)}.distribution-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;color:#afc2d6;font-size:11px}.distribution-heading small{color:#687f99;font-size:9px}.distribution-list{display:grid;gap:10px}.distribution-row{display:grid;grid-template-columns:42px minmax(0,1fr) 28px;gap:10px;align-items:center;color:#7187a1;font-size:9px}.distribution-row b{text-align:right;color:#c1d2e2;font-size:10px;font-weight:500}.status-track{height:5px;border-radius:5px;background:#1c3048;overflow:hidden}.status-track i{display:block;width:0;height:100%;border-radius:5px;transition:width .45s ease}.distribution-row:nth-child(1) .status-track i{background:var(--cyan)}.distribution-row:nth-child(2) .status-track i{background:var(--blue)}.distribution-row:nth-child(3) .status-track i{background:var(--purple)}.distribution-row:nth-child(4) .status-track i{background:var(--orange)}.connection-summary-copy{display:flex;align-items:center;gap:12px;padding:14px;border:1px solid rgba(98,230,212,.12);border-radius:14px;background:rgba(4,15,26,.3)}.connection-summary-icon{display:grid;place-items:center;width:33px;height:33px;border-radius:11px;background:rgba(98,230,212,.1);color:var(--cyan);font-size:17px}.connection-summary-copy b{display:block;color:#d5e5f4;font-size:12px;max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.connection-summary-copy small{display:block;margin-top:5px;color:#68809a;font-size:9px}.connection-summary-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;margin-top:12px;border:1px solid rgba(156,183,218,.1);border-radius:13px;overflow:hidden}.connection-summary-grid>div{padding:12px;background:rgba(4,13,24,.25)}.connection-summary-grid span{display:block;color:#647b96;font-size:9px;margin-bottom:5px}.connection-summary-grid b{display:block;color:#b9cde0;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.connection-summary-note{min-height:31px;margin:15px 0 14px;color:#6f86a0;font-size:10px;line-height:1.6}.connection-summary-panel>.quiet-button{width:100%;justify-content:center}.connection-settings-intro{margin:0 0 18px}.connection-drawer{width:min(510px,100%)}@media(max-width:1200px){.stat-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:780px){.overview-grid{grid-template-columns:1fr}.stat-grid{grid-template-columns:repeat(2,1fr)}.connection-drawer{width:100%}}
|
||||||
.retention-panel{max-width:980px}.retention-panel .panel-description{max-width:760px}.retention-form{max-width:820px}.retention-switch{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 17px;border:1px solid rgba(98,230,212,.16);border-radius:14px;background:rgba(4,15,26,.34)}.retention-switch b{display:block;color:#dceafb;font-size:11px;font-weight:550}.retention-switch small{display:block;margin-top:6px;color:#7086a0;font-size:9px;line-height:1.5}.switch{position:relative;display:block;width:43px;height:24px;flex:0 0 43px}.switch input{position:absolute;opacity:0;width:0;height:0}.switch span{position:absolute;inset:0;border-radius:999px;background:#243852;cursor:pointer;transition:.2s}.switch span:before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;border-radius:50%;background:#8ca3ba;transition:.2s}.switch input:checked+span{background:rgba(98,230,212,.55);box-shadow:0 0 18px rgba(98,230,212,.18)}.switch input:checked+span:before{transform:translateX(19px);background:#ecfffb}.retention-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px;margin-top:15px}.retention-field{display:block;padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.retention-field span{color:#afc2d6;font-size:10px}.retention-field small{float:right;color:#637b96;font-size:9px}.retention-field input{display:block;width:100%;margin-top:12px;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:13px;padding:10px}.retention-field input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-field-wide{grid-column:span 3}.retention-meta{display:flex;gap:25px;margin-top:16px;color:#637b96;font-size:9px}.retention-meta b{margin-left:5px;color:#afc2d6;font-weight:500}.retention-form>.field-label{margin-top:22px}.retention-form>textarea{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px;resize:vertical}.retention-form>textarea:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-form .action-form-buttons{justify-content:flex-start}.retention-form .action-error{min-height:18px}@media(max-width:780px){.retention-grid{grid-template-columns:1fr 1fr}.retention-field-wide{grid-column:span 2}.retention-meta{flex-direction:column;gap:7px}}
|
.retention-panel{max-width:980px}.retention-panel .panel-description{max-width:760px}.retention-form{max-width:820px}.retention-switch{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 17px;border:1px solid rgba(98,230,212,.16);border-radius:14px;background:rgba(4,15,26,.34)}.retention-switch b{display:block;color:#dceafb;font-size:11px;font-weight:550}.retention-switch small{display:block;margin-top:6px;color:#7086a0;font-size:9px;line-height:1.5}.switch{position:relative;display:block;width:43px;height:24px;flex:0 0 43px}.switch input{position:absolute;inset:0;z-index:1;opacity:0;width:100%;height:100%;margin:0;cursor:pointer}.switch input:focus-visible+span{outline:2px solid #9beade;outline-offset:3px}.switch input:disabled{cursor:not-allowed}.switch span{position:absolute;inset:0;border-radius:999px;background:#243852;cursor:pointer;transition:.2s}.switch span:before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;border-radius:50%;background:#8ca3ba;transition:.2s}.switch input:checked+span{background:rgba(98,230,212,.55);box-shadow:0 0 18px rgba(98,230,212,.18)}.switch input:checked+span:before{transform:translateX(19px);background:#ecfffb}.retention-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px;margin-top:15px}.retention-field{display:block;padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.retention-field span{color:#afc2d6;font-size:10px}.retention-field small{float:right;color:#637b96;font-size:9px}.retention-field input{display:block;width:100%;margin-top:12px;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:13px;padding:10px}.retention-field input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-field-wide{grid-column:span 3}.retention-meta{display:flex;gap:25px;margin-top:16px;color:#637b96;font-size:9px}.retention-meta b{margin-left:5px;color:#afc2d6;font-weight:500}.retention-form>.field-label{margin-top:22px}.retention-form>.retention-reason{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px;resize:vertical}.retention-form>.retention-reason:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-form .action-form-buttons{justify-content:flex-start}.retention-form .action-error{min-height:18px}@media(max-width:780px){.retention-grid{grid-template-columns:1fr 1fr}.retention-field-wide{grid-column:span 2}.retention-meta{flex-direction:column;gap:7px}}
|
||||||
.retention-tools{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-top:22px;padding-top:18px;border-top:1px solid rgba(156,183,218,.1)}.retention-select{display:flex;align-items:center;gap:10px;color:#9eb4cb;font-size:10px}.retention-select select{min-width:150px;border:1px solid rgba(156,183,218,.15);border-radius:9px;background:#0a1a2e;color:#dceafb;font:inherit;padding:9px 11px;outline:0}.retention-select select:focus{border-color:rgba(98,230,212,.58)}.retention-tool-buttons{display:flex;gap:9px}.secondary-button.compact{min-height:37px;padding:0 14px}.retention-preview{margin-top:13px;padding:13px 14px;border:1px solid rgba(156,183,218,.1);border-radius:11px;background:rgba(4,13,24,.28);color:#7189a3;font-size:10px;line-height:1.5}.retention-preview-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:8px;color:#c9d8e8}.retention-preview-head small{color:#7189a3}.retention-preview-row{display:grid;grid-template-columns:1fr auto 1.6fr;gap:12px;padding:7px 0;border-top:1px solid rgba(156,183,218,.08)}.retention-preview-row b{color:#9beade;font-weight:600}.retention-preview-row small{color:#7189a3;text-align:right}@media(max-width:780px){.retention-tools{align-items:stretch;flex-direction:column}.retention-tool-buttons{justify-content:flex-start}.retention-preview-row{grid-template-columns:1fr auto}.retention-preview-row small{grid-column:span 2;text-align:left}}
|
.retention-tools{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-top:22px;padding-top:18px;border-top:1px solid rgba(156,183,218,.1)}.retention-select{display:flex;align-items:center;gap:10px;color:#9eb4cb;font-size:10px}.retention-select select{min-width:150px;border:1px solid rgba(156,183,218,.15);border-radius:9px;background:#0a1a2e;color:#dceafb;font:inherit;padding:9px 11px;outline:0}.retention-select select:focus{border-color:rgba(98,230,212,.58)}.retention-tool-buttons{display:flex;gap:9px}.secondary-button.compact{min-height:37px;padding:0 14px}.retention-preview{margin-top:13px;padding:13px 14px;border:1px solid rgba(156,183,218,.1);border-radius:11px;background:rgba(4,13,24,.28);color:#7189a3;font-size:10px;line-height:1.5}.retention-preview-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:8px;color:#c9d8e8}.retention-preview-head small{color:#7189a3}.retention-preview-row{display:grid;grid-template-columns:1fr auto 1.6fr;gap:12px;padding:7px 0;border-top:1px solid rgba(156,183,218,.08)}.retention-preview-row b{color:#9beade;font-weight:600}.retention-preview-row small{color:#7189a3;text-align:right}@media(max-width:780px){.retention-tools{align-items:stretch;flex-direction:column}.retention-tool-buttons{justify-content:flex-start}.retention-preview-row{grid-template-columns:1fr auto}.retention-preview-row small{grid-column:span 2;text-align:left}}
|
||||||
|
|
||||||
|
.retention-tool-buttons{flex-wrap:wrap}.secondary-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid rgba(156,183,218,.18);border-radius:9px;background:rgba(23,46,65,.6);color:#b9cddd;font:inherit;font-size:10px;cursor:pointer}.secondary-button:hover{border-color:#62e6d4;color:#9beade}.retention-form button:disabled{opacity:.45;cursor:not-allowed}.retention-export-result{display:flex;flex-wrap:wrap;gap:10px;color:#9eb4cb;font-size:10px;line-height:1.6}.retention-export-result a{color:#62e6d4}.retention-export-result code{width:100%;font-size:9px;overflow-wrap:anywhere;color:#7189a3}.retention-export-result [hidden]{display:none}.retention-tools{flex-wrap:wrap}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ grep -q 'source_kind=SCHEDULE' "$E2E_DIR/schedule.log"
|
|||||||
printf '%s\n' "[8/8] final console data and logout check"
|
printf '%s\n' "[8/8] final console data and logout check"
|
||||||
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_QUEUE="$E2E_QUEUE" ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" \
|
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_QUEUE="$E2E_QUEUE" ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" \
|
||||||
ORBIT_E2E_USER="$E2E_ADMIN" python3 - <<'PY'
|
ORBIT_E2E_USER="$E2E_ADMIN" python3 - <<'PY'
|
||||||
|
import hashlib
|
||||||
import http.cookiejar
|
import http.cookiejar
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -194,10 +195,12 @@ password = pathlib.Path(os.environ["ORBIT_E2E_PASSWORD_FILE"]).read_text(encodin
|
|||||||
jar = http.cookiejar.CookieJar()
|
jar = http.cookiejar.CookieJar()
|
||||||
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
|
|
||||||
def request(path, method="GET", body=None):
|
def request(path, method="GET", body=None, headers=None):
|
||||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, method=method, data=data)
|
req = urllib.request.Request(base + path, method=method, data=data)
|
||||||
req.add_header("Accept", "application/json")
|
req.add_header("Accept", "application/json")
|
||||||
|
for name, value in (headers or {}).items():
|
||||||
|
req.add_header(name, value)
|
||||||
if body is not None:
|
if body is not None:
|
||||||
req.add_header("Content-Type", "application/json")
|
req.add_header("Content-Type", "application/json")
|
||||||
try:
|
try:
|
||||||
@@ -213,10 +216,37 @@ assert status == 200, status
|
|||||||
items = json.loads(body).get("data", {}).get("items", [])
|
items = json.loads(body).get("data", {}).get("items", [])
|
||||||
assert any(item.get("state") == "SUCCEEDED" for item in items), "no succeeded task visible in console scope"
|
assert any(item.get("state") == "SUCCEEDED" for item in items), "no succeeded task visible in console scope"
|
||||||
assert any(item.get("handler", "").startswith("text.normalize.runtime.") for item in items)
|
assert any(item.get("handler", "").startswith("text.normalize.runtime.") for item in items)
|
||||||
|
status, body = request("/console/retention")
|
||||||
|
assert status == 200, (status, body)
|
||||||
|
policy = json.loads(body)["data"]
|
||||||
|
assert policy["revision"] == "0" and policy["enabled"] is False
|
||||||
|
update = {key: policy[key] for key in ("enabled", "idempotency_days", "task_history_days", "workflow_history_days", "event_log_days", "delivery_history_days", "inbox_history_days", "audit_log_days")}
|
||||||
|
update.update(expected_revision="0", enabled=True, reason="console retention end-to-end validation")
|
||||||
|
headers = {"Idempotency-Key": "e2e-retention-save"}
|
||||||
|
status, body = request("/console/retention", "PUT", update, headers)
|
||||||
|
assert status == 200 and json.loads(body)["data"]["revision"] == "1", (status, body)
|
||||||
|
status, body = request("/console/retention", "PUT", update, headers)
|
||||||
|
assert status == 200 and json.loads(body)["data"]["revision"] == "1", (status, body)
|
||||||
|
status, _ = request("/console/retention", "PUT", update, {"Idempotency-Key": "e2e-retention-stale"})
|
||||||
|
assert status == 409, status
|
||||||
|
status, body = request("/console/retention/preview")
|
||||||
|
assert status == 200 and len(json.loads(body)["data"]["items"]) == 7, (status, body)
|
||||||
|
status, body = request("/console/retention/export", "POST", {"kind": "tasks", "expected_revision": "1", "limit": 20})
|
||||||
|
assert status == 200, (status, body)
|
||||||
|
snapshot = json.loads(body)["data"]
|
||||||
|
assert snapshot["format_version"] == 1
|
||||||
|
assert hashlib.sha256(snapshot["content"].encode("utf-8")).hexdigest() == snapshot["sha256"]
|
||||||
|
content = json.loads(snapshot["content"])
|
||||||
|
assert content["source_retained"] is True and content["root_count"] == 0
|
||||||
|
status, _ = request("/console/retention/export", "POST", {"kind": "tasks", "expected_revision": "0"})
|
||||||
|
assert status == 409, status
|
||||||
|
print("retention_policy=ok replay=ok stale_revision=409 preview_categories=7 empty_export_digest=ok")
|
||||||
status, _ = request("/console/auth/logout", "POST", {})
|
status, _ = request("/console/auth/logout", "POST", {})
|
||||||
assert status == 200, status
|
assert status == 200, status
|
||||||
status, _ = request("/v1/tasks?" + urllib.parse.urlencode({"queue_id": queue, "limit": 1}))
|
status, _ = request("/v1/tasks?" + urllib.parse.urlencode({"queue_id": queue, "limit": 1}))
|
||||||
assert status == 401, status
|
assert status == 401, status
|
||||||
|
status_retention, _ = request("/console/retention")
|
||||||
|
assert status_retention == 401, status_retention
|
||||||
print("console_task_data=ok succeeded_tasks=%d logout=ok unauthorized_after_logout=%d" % (sum(item.get("state") == "SUCCEEDED" for item in items), status))
|
print("console_task_data=ok succeeded_tasks=%d logout=ok unauthorized_after_logout=%d" % (sum(item.get("state") == "SUCCEEDED" for item in items), status))
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user