Blog
Dashboard 手动采集与刷新:从按钮到数据重绘的完整闭环
从浏览器按钮、异步任务和跨进程文件锁出发,逐步理解 scheduler、review gate、SQLite、Dashboard 文件发布与前端原子重绘。
Dashboard 手动采集与刷新:项目教程
本教程面向第一次接触本项目、Python 后端、浏览器异步任务或跨进程互斥的读者。它不只解释右上角按钮,还沿着“浏览器 → HTTP 服务 → 后台任务 → scheduler → review gate → Lumi API → SQLite → Dashboard 文件 → 浏览器重绘”追踪完整闭环。
1. 如何使用本教程
1.1 你会学到什么
读完后,你应该能够回答:
- 原来的“OpenClaw 运营态势”为什么没有实际作用?
- 点击“刷新数据”后,为什么接口先返回任务编号,而不是让 HTTP 请求一直等待?
- 手动刷新与定时刷新如何复用同一业务链路?
- 两个进程同时刷新时,文件锁如何避免 SQLite 和 Dashboard 并发写入?
- 采集、聚合、生成 JSON、生成 HTML、前端重绘分别发生在哪里?
- 失败时哪些旧数据会被保留,哪些数据可能已经写入?
- 当前实现有哪些边界仍依赖部署环境,而不是代码本身?
1.2 建议先具备的知识
不要求你熟悉整个仓库,但最好知道:
- HTTP 的
GET和POST是两种请求方法; - JSON 是前后端交换结构化数据的一种文本格式;
- SQLite 是一个以单个文件保存数据的数据库;
- Python 进程可以再启动子进程;
- Git 分支用于隔离一组相关改动。
1.3 推荐阅读顺序
如果只想先理解业务,请按以下顺序阅读:
- 第 2 节:确认教程分析的是哪个分支;
- 第 3 节:理解改造前后;
- 第 4 节:看总体架构图;
- 第 6 节:沿成功路径走一遍;
- 第 9 节:理解失败时会发生什么;
- 第 10 节:再回到具体代码。
如果要维护代码,再继续阅读测试、设计取舍、未完成接线和变更索引。
2. 需求与证据边界
2.1 业务目标
需求原文的核心是:
右上角“OpenClaw 运营态势”如果没有实际作用,就改成刷新按钮。点击后主动刷新,同步触发采集脚本并刷新 Dashboard。
代码把“同步触发”实现为“用户的一次点击对应一个完整刷新任务”,但没有让单个 HTTP 请求同步阻塞到采集结束。浏览器先创建后台任务,再轮询任务状态。这一点是理解后续设计的关键。
2.2 Git 分析范围
| 项目 | 值 |
|---|---|
| 功能分支 | 手动刷新功能开发分支 |
| 功能分支状态 | 已完成并通过验证 |
| 显式基线 | 上一阶段的 Dashboard 刷新分支 |
| Merge base | 与显式基线 HEAD 一致 |
| 基线是否为功能分支祖先 | 是 |
| 功能提交数 | 24 |
| 已提交变更文件数 | 21 |
| Staged | 无 |
| Unstaged | 无 |
| Untracked | 无 |
本教程使用显式基线,而不是工具自动选择的 origin。原因是需求附件和分支历史都明确表明:手动刷新功能从上一阶段的 Dashboard 刷新分支继续实现;两者的 merge base 正好是基线 HEAD。
当前主工作树停留在基线分支,功能代码位于 Git linked worktree:
.worktrees/scheduler-manual-refresh
因此,教程描述的是该 linked worktree 中已经提交的功能实现,不把主工作树内其他未跟踪产物算入功能范围。
2.3 24 个提交如何组成该功能
这些提交可以按职责分成七组:
| 阶段 | 代表提交 | 作用 |
|---|---|---|
| 测试策略 | 测试策略提交 | 把 Dashboard 测试方向调整为行为约束 |
| 互斥 | 互斥实现提交 | 新增跨进程锁,并让 scheduler 周期串行 |
| 子进程边界 | 子进程治理提交 | 有界运行、进程树收拢、先收拢再执行 |
| 作业管理 | 作业管理提交 | 管理异步刷新任务并处理启动失败 |
| HTTP API | API 实现提交 | 暴露固定 API 并收紧请求解析 |
| 服务与文件发布 | 服务与发布提交 | 接入监控服务、启动关闭、Dashboard 原子替换和清理 |
| 前端与收尾 | 前端与文档提交 | 按钮、轮询、校验、生命周期、文档和测试 |
2.4 什么是事实,什么不是
本文采用四类证据标签:
- 代码事实:源码、配置、测试或 Git 历史直接证明;
- 有依据的推断:多个代码事实共同支持,但运行环境没有直接证明;
- 未知:仓库证据不足;
- 改进建议:当前未实现的机制。
特别需要避免以下误读:
- 代码实现同源校验,不等于实现了用户登录鉴权;
- 文件锁能串行 scheduler,不等于所有直接运维命令都被锁保护;
- 两个文件分别原子替换,不等于 JSON 与 HTML 构成一个事务;
- 单元测试通过,不等于已经在真实 Lumi、Ingress 或 VKE 环境完成验收。
3. 改造前后
3.1 改造前
基线中的右上角元素是一个静态 <div>:
<div class="header-badge" aria-label="OpenClaw 运营态势">
OpenClaw 运营态势
</div>
它没有点击监听器、没有 API 调用,也不读取任何 OpenClaw 状态。因此,“运营态势”只是视觉标签。
基线已经存在每 5 分钟自动刷新,但它只执行:
GET dashboard-data.json
这只会读取已经生成的静态 JSON,不会触发 Lumi API 采集,也不会重建 Dashboard。
证据:
cloud_usage_monitor/dashboard_assets/lumi_dashboard_template.html— 基线右上角静态标签;cloud_usage_monitor/dashboard_assets/lumi_dashboard.js— 基线refreshDashboardData()与startDashboardAutoRefresh()。
3.2 改造后
右上角变成语义化 <button>,一次点击会:
POST /api/refresh创建或复用一个后台任务;- 每秒
GET /api/refresh/{job_id}查询状态; - 服务端固定启动
scheduler.py --once; - scheduler 获取共享文件锁;
- 先执行门禁后的
collect; - 采集成功后执行门禁后的
dashboard; - 生成并替换
dashboard-data.json和index.html; - 浏览器强制读取新 JSON;
- JSON 通过结构校验后,在离屏 DOM 中完整渲染;
- 渲染成功后一次性替换当前页面主体。
3.3 保持不变的行为
以下行为有意保留:
- 每 5 分钟自动检查仍然存在;
- 自动检查仍然只读取 JSON,不触发采集;
- 定时更新仍由
scripts/scheduler.py执行; - 真正的采集、聚合与 Dashboard 生成仍走现有 Python 业务代码;
- 云侧访问仍是 Lumi 查询 API;
- Dashboard 仍由静态
index.html和dashboard-data.json提供。
4. 仓库与模块地图
4.1 与本功能直接相关的目录
lumi-openclaw-monitor/
├─ cloud_usage_monitor/
│ ├─ dashboard.py # 渲染并发布 Dashboard 文件
│ ├─ dashboard_http.py # 静态文件服务 + 手动刷新 API
│ ├─ dashboard_refresh.py # 后台作业和有界子进程
│ ├─ process_lock.py # Windows/Linux 跨进程文件锁
│ ├─ lumi.py # Lumi API 采集和 SQLite 写入
│ ├─ lumi_aggregates.py # 资源维度、日/小时聚合
│ ├─ lumi_ops.py # 运营汇总和告警
│ ├─ lumi_analytics.py # Dashboard JSON payload
│ └─ dashboard_assets/
│ ├─ lumi_dashboard_template.html
│ ├─ lumi_dashboard.css
│ └─ lumi_dashboard.js
├─ scripts/
│ ├─ serve_monitoring.py # 长期 HTTP 服务入口
│ ├─ scheduler.py # 定时或单次刷新入口
│ └─ run_after_review.py # review gate 后执行任务
├─ tests/ # 各层单元和集成式测试
├─ work/
│ ├─ usage.db # SQLite 数据库
│ └─ usage.db.scheduler.lock # scheduler 互斥锁文件
└─ outputs/dashboard/
├─ dashboard-data.json
└─ index.html
4.2 总体架构
flowchart LR
Browser["内网浏览器"] --> Monitor["serve_monitoring.py"]
Monitor --> Static["静态 Dashboard 文件"]
Browser -->|"POST /api/refresh"| Api["DashboardHandler.do_POST()"]
Api --> Jobs["RefreshJobManager"]
Jobs --> Once["scheduler.py --once"]
Timer["scheduler.py 定时循环"] --> Lock["usage.db.scheduler.lock"]
Once --> Lock
Lock --> CollectGate["run_after_review.py collect"]
CollectGate --> Review1["review_gate.run_review()"]
Review1 --> Lumi["Lumi 查询 API"]
Lumi --> SQLite["work/usage.db"]
SQLite --> DashboardGate["run_after_review.py dashboard"]
DashboardGate --> Review2["review_gate.run_review()"]
Review2 --> Payload["build_lumi_dashboard_payload()"]
Payload --> Publish["write_lumi_dashboard_files()"]
Publish --> Json["dashboard-data.json"]
Publish --> Html["index.html"]
Json --> Static
Html --> Static
Browser -->|"GET /api/refresh/{job_id}"| Api
Browser -->|"GET dashboard-data.json"| Static
这里最重要的结构关系是:手动刷新没有复制采集逻辑,而是启动 scheduler 的单次模式。这样手动和定时路径在锁、门禁、采集、聚合和文件生成上尽量保持一致。
4.3 进程边界
运行时至少涉及三类执行单元:
serve_monitoring.py主进程:- 提供 8780 Dashboard;
- 提供 8765 SQLite 只读管理页;
- 保存内存中的刷新任务状态。
- 刷新工作线程:
- 由
RefreshJobManager创建; - 不直接采集,只负责运行和等待子进程。
- 由
- scheduler 子进程及其后代:
- 执行
scheduler.py --once; - 再分别启动
run_after_review.py collect和dashboard。
- 执行
这也是代码必须处理“线程关闭”“子进程超时”“孙进程残留”和“进程树终止”的原因。
5. 核心概念与数据契约
5.1 异步作业
采集可能耗时较长。如果 POST /api/refresh 一直等待,会受到浏览器、反向代理或 Ingress 超时影响。
当前协议是:
POST /api/refresh
立即返回 202 Accepted:
{
"job_id": "32位十六进制编号",
"status": "running",
"started_at": "2026-07-27T...",
"finished_at": null,
"message": "正在采集并刷新"
}
浏览器随后请求:
GET /api/refresh/{job_id}
状态只有三种:
| 状态 | 含义 |
|---|---|
running |
子进程仍在执行 |
succeeded |
scheduler 子进程退出码为 0 |
failed |
非零退出、超时、取消或内部异常 |
证据:cloud_usage_monitor/dashboard_refresh.py — RefreshJob、RefreshJobManager。
5.2 固定命令,而不是远程命令执行
API 不接收 shell 命令、配置路径或任务类型。build_scheduler_once_request() 固定构造:
当前 Python 解释器
scripts/scheduler.py
--once
--config
服务启动时解析出的 config 路径
并使用 shell=False。
这缩小了接口输入面:浏览器只能请求“执行预定义的一次刷新”,不能借接口拼接任意命令。
证据:cloud_usage_monitor/dashboard_refresh.py — build_scheduler_once_request()、run_bounded_process()。
5.3 文件锁
锁文件位置由 SQLite 路径派生:
flowchart LR
Database["work/usage.db"] --> LockFile["work/usage.db.scheduler.lock"]
acquire_file_lock() 在:
- Windows 使用
msvcrt.locking(); - Linux/Unix 使用
fcntl.flock()。
它是操作系统级跨进程锁,不只是 Python 线程锁。因此长期 scheduler 和手动启动的 scheduler 子进程可以互相看到锁。
默认等待锁 15 分钟。超时后 scheduler 返回非零退出码。
证据:
cloud_usage_monitor/process_lock.py—refresh_lock_path()、acquire_file_lock();scripts/scheduler.py—DEFAULT_LOCK_TIMEOUT_SECONDS、run_cycle()。
5.4 线程锁与文件锁不是同一个锁
本功能有两层互斥:
| 互斥层 | 保护范围 | 实现 |
|---|---|---|
| 线程锁 | 同一个监控服务进程内只保留一个 active manual job | RefreshJobManager._lock |
| 文件锁 | 不同 scheduler 进程之间只运行一个 collect/dashboard 周期 | usage.db.scheduler.lock |
重复点击时,RefreshJobManager.start_or_reuse() 返回正在运行的同一个 job,不新建第二个线程。
如果同一份存储被两个独立监控服务副本同时挂载,两者的内存 job manager 互相不可见,但它们启动的 scheduler 仍会在文件锁处排队。前提是两个副本真的共享同一个支持该锁语义的文件系统。
5.5 Review gate
run_after_review.py 在执行任务前调用 review_gate.run_review()。门禁包括:
- 必需文件检查;
- 配置加载和路径边界;
- 配置内联 secret 检查;
- 凭证来源检查;
- provider 边界检查;
- Python 编译检查;
- 单元测试;
- mock smoke flow。
一次 scheduler 周期分别启动 collect 和 dashboard 两个门禁进程,因此正常情况下门禁会运行两次。
证据:
scripts/run_after_review.py—main();cloud_usage_monitor/review_gate.py—run_review()。
5.6 Dashboard payload
build_lumi_dashboard_payload() 从 SQLite 生成前端 JSON,主要字段是:
| 字段 | 用途 |
|---|---|
generated_at |
本次 Dashboard 生成时间 |
data_end_at |
数据实际截止时间 |
summary |
余额、额度、确认消耗、预扣 |
daily_trend |
每日趋势 |
user_rankings |
用户排名 |
service_rankings |
服务排名 |
work_rankings |
作品/任务关联排名 |
user_resource_breakdown |
用户资源明细 |
operations_overview |
运营汇总和对比 |
user_deep_analysis |
用户深度分析 |
recent_logs |
最近消费记录 |
alerts |
告警事件 |
前端不会只检查 generated_at。isDashboardData() 对顶层对象、时间戳、数字字段和列表中每行结构进行校验。校验失败时,不会用坏数据替换当前页面。
证据:
cloud_usage_monitor/lumi_analytics.py—build_lumi_dashboard_payload();cloud_usage_monitor/dashboard_assets/lumi_dashboard.js—isDashboardData()。
5.7 超时关系
| 层次 | 默认时间 | 说明 |
|---|---|---|
| 自动读取 JSON | 30 秒 | 只读自动检查 |
| 文件锁等待 | 15 分钟 | scheduler 等待另一个周期结束 |
| 手动 scheduler 子进程 | 30 分钟 | 包含锁等待、门禁、采集和 Dashboard |
| 浏览器整个手动流程 | 31 分钟 | 创建任务、轮询和最终 JSON 读取共用一个 deadline |
| 手动轮询间隔 | 1 秒 | GET /api/refresh/{job_id} |
这些 deadline 使用 performance.now() 或 time.monotonic(),不依赖用户修改系统墙上时钟。
6. 端到端成功路径
6.1 时序图
sequenceDiagram
actor User as 用户
participant Browser as 浏览器
participant HTTP as DashboardHandler
participant Jobs as RefreshJobManager
participant Scheduler as scheduler.py --once
participant Gate as run_after_review.py
participant Lumi as Lumi API
participant DB as SQLite
participant Files as Dashboard 文件
User->>Browser: 点击“刷新数据”
Browser->>HTTP: POST /api/refresh
HTTP->>Jobs: start_or_reuse()
Jobs-->>HTTP: running job
HTTP-->>Browser: 202 + job_id
Jobs->>Scheduler: 启动固定子进程
Scheduler->>Scheduler: 获取 scheduler 文件锁
Scheduler->>Gate: collect
Gate->>Gate: 运行 review gate
Gate->>Lumi: 查询余额、用户、消费明细
Lumi-->>Gate: API 响应
Gate->>DB: 写入原始数据与聚合
Scheduler->>Gate: dashboard
Gate->>Gate: 再次运行 review gate
Gate->>DB: 读取并重建聚合
Gate->>Files: 先替换 JSON,再替换 HTML
Scheduler-->>Jobs: 退出码 0
loop 每 1 秒
Browser->>HTTP: GET /api/refresh/{job_id}
HTTP-->>Browser: running 或 succeeded
end
Browser->>HTTP: GET dashboard-data.json
HTTP-->>Browser: 新 payload
Browser->>Browser: 校验、离屏渲染、替换 body
Browser-->>User: 显示“已刷新”
6.2 逐步讲解
第 1 步:按钮进入运行态
runManualRefresh() 先检查 manualRefreshRunning,避免同一页面重复触发。然后:
- 保存按钮是否原本有焦点;
- 设置按钮
disabled; - 设置
aria-busy="true"; - 把文案改为“正在采集并刷新”;
- 取消旧自动读取结果的生效资格。
证据:cloud_usage_monitor/dashboard_assets/lumi_dashboard.js — setManualRefreshState()、runManualRefresh()。
第 2 步:POST 创建任务
浏览器只发送空 POST。服务端要求原始 target 必须精确等于:
/api/refresh
以下请求都不会启动任务:
/api/refresh?force=1
//api/refresh
http://host/api/refresh
带非空 body 的请求
带 Transfer-Encoding 的请求
证据:cloud_usage_monitor/dashboard_http.py — DashboardHandler.do_POST()。
第 3 步:同源检查
服务端比较 Origin 和 Host 的规范化主机与端口:
- 支持 HTTP/HTTPS 默认端口;
- 处理 IPv4、IPv6 和域名;
- 拒绝用户名、密码、非法端口、空格、zone id 等异常形式;
- 不信任
X-Forwarded-*来改变判断。
如果浏览器没有发送 Origin,只要 Host 合法,当前实现仍接受请求。这兼容非浏览器或部分同源客户端,但也说明它不是身份认证。
证据:cloud_usage_monitor/dashboard_http.py — _normalized_authority()、origin_matches_host()。
第 4 步:创建或复用内存任务
RefreshJobManager.start_or_reuse() 在内部线程锁下检查:
- 服务正在关闭:抛出
RefreshJobManagerClosed,API 返回 503; - 已有 active job:直接返回该 job;
- 没有 active job:创建新 job 和非 daemon 工作线程。
终态任务最多保留 128 个,超出后删除较旧记录。服务重启后,内存 job 历史会丢失。
第 5 步:有界启动 scheduler
工作线程调用 run_bounded_process()。它:
- 不提供 stdin;
- 合并 stdout/stderr;
- 最多保留 64 KiB 输出尾部;
- Windows 用 Job Object 收拢进程树;
- Unix 用新 session/process group 收拢进程树;
- 超时或关闭时终止整个进程树;
- API 和生命周期日志都不返回采集子进程原始输出。
“只保留输出尾部”可防止长时间任务无限占用内存;“不把输出暴露给 API”可减少凭证、路径或内部错误信息泄漏。
第 6 步:scheduler 获取文件锁
scheduler.py --once 把 max_runs 设为 1。run_cycle() 获取文件锁后,锁覆盖完整的:
flowchart LR
Collect["collect"] --> Dashboard["dashboard"]
锁在周期结束或异常退出时由 context manager 释放。scheduler 睡眠期间不持锁。
第 7 步:门禁后的采集
scheduler 调用:
python scripts/run_after_review.py collect --config <固定配置>
门禁通过后,_run_collect() 调用 collect_lumi_usage():
ListMyLumiCombos:读取算力包余额;ListLumiUsers:同步用户;ListLumiConsumeDetailLog:分页拉取消费明细;- 写入余额快照、用户、消费日志、采集覆盖和 watermark;
- 按资源、用户、日、小时重建聚合;
- 重建运营汇总和告警。
所有云 API 都由 VolcLumiClient 发出查询请求。仓库代码没有通过该按钮调用创建、删除、授权或修改套餐接口。
证据:
scripts/run_after_review.py—_run_collect();cloud_usage_monitor/lumi.py—VolcLumiClient、collect_lumi_usage()。
第 8 步:门禁后的 Dashboard
只有 collect 返回 0,scheduler 才继续:
python scripts/run_after_review.py dashboard --config <固定配置>
_run_dashboard():
- 重新连接 SQLite;
- 初始化表;
- 重建聚合和运营数据;
- 生成 payload;
- 写入 Dashboard 文件。
如果 collect 失败,Dashboard 步骤会被跳过。
第 9 步:发布两个文件
write_lumi_dashboard_files() 先在目标目录中创建临时文件:
.dashboard-data.json.<随机>.tmp
.index.html.<随机>.tmp
每个临时文件都:
- 写入完整内容;
flush();fsync();- 关闭。
然后按顺序执行:
os.replace(JSON 临时文件, dashboard-data.json)
os.replace(HTML 临时文件, index.html)
同目录 os.replace() 让每个目标文件不会暴露“只写了一半”的内容。
第 10 步:浏览器加载并原子重绘
job 成功后,浏览器强制读取 JSON。它先验证完整 schema,再:
- 克隆当前
<body>; - 把新数据渲染到克隆 body;
- 如果渲染成功,一次性
replaceWith(); - 最后才把全局
data指向新 payload。
如果校验或离屏渲染失败,当前 body 和旧 data 保持不变。
证据:cloud_usage_monitor/dashboard_assets/lumi_dashboard.js — isDashboardData()、renderNextDashboardData()。
7. 数据与状态变化
7.1 刷新任务状态机
stateDiagram-v2
[*] --> Running: "start_or_reuse()"
Running --> Running: "重复点击复用同一 job"
Running --> Succeeded: "子进程退出码为 0"
Running --> Failed: "非零退出"
Running --> Failed: "超时"
Running --> Failed: "服务关闭取消"
Running --> Failed: "内部异常"
Succeeded --> [*]
Failed --> [*]
终态 message 被刻意收敛:
- 成功:
数据采集与 Dashboard 刷新完成 - 失败:
刷新失败,仍显示上次数据
具体堆栈和子进程输出不会返回浏览器。
7.2 SQLite 写入
采集会影响至少以下持久化对象:
| 表/对象 | 写入方式 | 作用 |
|---|---|---|
lumi_balance_snapshots |
插入 | 保存余额快照 |
lumi_users |
upsert | 同步用户信息 |
lumi_consume_logs |
insert/update | 保存消费明细并处理状态更新 |
lumi_collection_state |
upsert | 保存增量采集 watermark |
lumi_collection_coverage |
合并区间 | 记录成功采集覆盖 |
lumi_resource_dimensions |
重建 | 资源维度 |
lumi_user_resource_daily/hourly |
重建 | 日/小时资源聚合 |
lumi_user_daily_summary |
重建 | 用户运营汇总 |
lumi_service_daily_summary |
重建 | 服务运营汇总 |
lumi_alert_events |
重建 | 告警事件 |
unique_measure_id 是消费日志的业务去重键。相同编号只有在新记录 updated_at 更晚,或时间相同但内容变化时才更新。
证据:cloud_usage_monitor/lumi.py — _upsert_consume_logs()。
7.3 文件发布状态
flowchart TD
Old["旧 JSON + 旧 HTML"] --> StageJson["完整写入 JSON 临时文件"]
StageJson --> StageHtml["完整写入 HTML 临时文件"]
StageHtml --> ReplaceJson["原子替换 dashboard-data.json"]
ReplaceJson --> ReplaceHtml["原子替换 index.html"]
ReplaceHtml --> New["新 JSON + 新 HTML"]
ReplaceJson -. "HTML 替换失败" .-> Mixed["新 JSON + 旧 HTML"]
图中的虚线不是设计目标,而是代码明确允许的失败窗口。
如果 HTML 替换失败:
- 手动任务报告失败;
- 新 JSON 可能已经发布;
- 旧 HTML 仍可继续服务;
- 后续每 5 分钟自动检查可能读取并校验新 JSON,然后更新页面。
因此,“失败时仍显示上次数据”是前端安全文案和常见结果,不是两个文件都必然回滚的事务保证。
7.4 页面内状态
前端有三个相关状态:
| 状态 | 变量/元素 | 用途 |
|---|---|---|
| 手动作业运行 | manualRefreshRunning |
阻止同页重复点击和自动检查 |
| 自动读取运行 | automaticRefreshRunning |
防止自动请求重叠 |
| 数据请求序号 | dashboardDataRequestSequence |
让较旧响应失效 |
当手动刷新开始时,序号递增。即使此前的自动 GET 较晚返回,它也不能覆盖手动流程准备加载的新数据。
8. 上游与下游闭环
8.1 上游入口
本功能有两个上游:
用户手动入口
flowchart LR
Button["浏览器按钮"] --> Post["POST /api/refresh"]
Post --> Manager["RefreshJobManager"]
Manager --> Scheduler["scheduler.py --once"]
定时入口
flowchart LR
Scheduler["长期 scheduler.py"] --> Wait["每个周期完成后等待 3600 秒"]
Wait --> Next["运行下一周期"]
Next --> Scheduler
两者在 run_cycle() 和文件锁处汇合。
8.2 内部下游
从 scheduler 向下依赖:
flowchart TD
Runner["run_after_review.py"] --> Gate["review_gate.py"]
Gate --> Config["config.py / credentials.py"]
Config --> Lumi["lumi.py"]
Lumi --> Database["db.py"]
Database --> Aggregates["lumi_aggregates.py"]
Aggregates --> Ops["lumi_ops.py"]
Ops --> Analytics["lumi_analytics.py"]
Analytics --> Dashboard["dashboard.py"]
这些模块大多不是本分支新增,但必须阅读它们才能理解按钮到底刷新了什么。
8.3 外部边界
| 边界 | 代码能证明的内容 | 不能仅凭仓库确认的内容 |
|---|---|---|
| Lumi API | 调用余额、用户、消费明细查询接口,单次请求 timeout 30 秒 | 生产账号实际 IAM 权限、API 可用性和限流 |
| 浏览器 | 通过同源 HTTP API 创建/查询任务 | 用户身份、SSO 会话和组织权限 |
| 文件系统 | 使用 SQLite、lock 文件和 Dashboard 文件 | VKE PVC 是否完整支持跨 Pod 文件锁语义 |
| 网络 | 服务默认可绑定 0.0.0.0,文档建议回环/内网/网关 |
真实环境是否配置 NetworkPolicy、Ingress 鉴权或防火墙 |
| 容器编排 | 镜像默认启动 serve_monitoring.py |
现有 VKE manifest 是否另有未入库 scheduler 工作负载 |
8.4 部署闭环
代码要求两个长期角色共享:
- 同一份 config;
- 同一个
work/; - 同一个
outputs/。
推荐启动顺序是:
- 启动监控服务;
- 等待启动时 Dashboard 构建完成;
- 验证 8780;
- 再启动长期 scheduler。
监控服务启动时,_refresh_dashboard() 也会获取 scheduler 锁并先生成一次 Dashboard,然后才创建和监听 HTTP server。这样首个用户请求不应读到尚未生成的文件。
但是,仓库中的 deploy/lumi-dashboard-vke.yaml 当前只定义一个默认监控服务容器,没有定义 scheduler sidecar 或独立 Deployment。文档明确要求部署方补充 scheduler。这是当前仓库内最重要的“未完全接线”。
9. 失败路径与保护机制
9.1 失败矩阵
| 失败点 | 当前行为 | 调用方看到什么 | 是否已有重试/回滚 |
|---|---|---|---|
| 非法 POST target/body | 不创建任务 | 400/404 JSON | 无需重试 |
| Origin 与 Host 不匹配 | 不创建任务 | 403 JSON | 无 |
| 服务正在关闭 | 拒绝新任务 | 503 JSON | 浏览器显示失败 |
| 工作线程启动失败 | job 立即转为 failed | 安全失败文案 | 下次可新建 job |
| 文件锁 15 分钟未取得 | scheduler 返回 1 | job failed | 无自动重试 |
| review gate 失败 | 任务不执行 | job failed | 无自动重试 |
| collect 非零退出 | 跳过 dashboard | job failed | 无自动重试 |
| Lumi API 超时/错误 | collect 失败 | job failed | 客户端单请求 timeout 30 秒;未发现业务重试 |
| dashboard 非零退出 | 本轮文件不完整更新或部分更新 | job failed | 无跨文件回滚 |
| 子进程超过 30 分钟 | 终止进程树 | job failed | 无自动重试 |
| 监控服务关闭 | 设置 cancel event,终止子进程树并 join | 任务最终 failed | 关闭流程等待清理 |
| 最终 JSON fetch 失败 | 不替换页面 | “刷新失败,仍显示上次数据” | 5 分钟自动检查可能稍后恢复 |
| JSON schema 无效 | 不替换页面 | 刷新失败 | 保留最后有效页面 |
| 新数据渲染异常 | 不替换当前 body | 刷新失败 | 保留最后有效页面 |
9.2 collect 失败后的部分成功窗口
这是一个容易遗漏的细节。
collect_lumi_usage() 在写完原始采集数据、coverage 和 watermark 后会 conn.commit()。随后 _run_collect() 才重建 aggregate 和 ops,而这些函数也分别提交。
因此,如果:
- 原始采集提交成功;
- 后续聚合失败;
那么 scheduler 会把 collect 视为失败并跳过 Dashboard,但 SQLite 中可能已经包含新的原始数据。下一次成功运行 dashboard 时,可能会把这些数据纳入展示。
当前代码没有跨“原始采集 + 聚合 + Dashboard 文件”的全局事务。这是合理的工程取舍,但不能描述成全链路原子刷新。
9.3 文件发布的一致性窗口
单文件写入采用临时文件 + os.replace(),能避免读到半个 JSON 或半个 HTML。
但发布顺序是:
flowchart LR
Json["发布新 JSON"] --> Html["发布新 HTML"]
所以 HTML 替换失败时会留下“新 JSON + 旧 HTML”。这是有意记录并由测试覆盖的行为,不是未处理异常。
9.4 已实现的关闭保护
服务收到 SIGTERM 时:
- 信号处理器转为
KeyboardInterrupt; RefreshJobManager.shutdown()拒绝新任务;- 设置 active job 的 cancel event;
- 等待非 daemon 工作线程退出;
- 关闭 admin 和 Dashboard server;
- join admin thread;
- 恢复原来的 SIGTERM handler。
子进程层会进一步终止整个进程树,避免只杀 scheduler 根进程却留下门禁、采集或测试孙进程。
9.5 没有实现的机制
以下机制在当前代码中没有被证明存在:
- HTTP 登录鉴权或基于角色的授权;
- 分布式 job 存储;
- job 状态跨服务重启恢复;
- 指数退避重试;
- 死信队列;
- 跨 SQLite、JSON、HTML 的事务;
- 多副本 leader election;
- metrics endpoint 或分布式 tracing;
- 直接运维命令自动参与 scheduler 锁。
它们应放在改进建议中,而不能描述为当前能力。
10. 详细代码导读
10.1 从静态徽标到可访问按钮
文件:
cloud_usage_monitor/dashboard_assets/lumi_dashboard_template.html—manualRefreshButton;cloud_usage_monitor/dashboard_assets/lumi_dashboard.css—.refresh-button;cloud_usage_monitor/dashboard_assets/lumi_dashboard.js—setManualRefreshState()。
按钮使用原生 <button type="button">,而不是给 <div> 绑定点击事件。这样浏览器默认支持:
- Tab 键聚焦;
- Enter/Space 激活;
- disabled 状态;
- 辅助技术识别。
状态区域使用:
role="status"
aria-live="polite"
运行时按钮旋转;prefers-reduced-motion 用户不会被强制动画。运行结束后,如果按钮原本有焦点,代码用 focus({preventScroll: true}) 恢复焦点。
10.2 前端只有一个底层 fetch 入口
fetchJsonBeforeDeadline() 是所有请求的底层封装:
- 根据总 deadline 计算剩余时间;
- 为每次 fetch 创建
AbortController; - deadline 到达时 abort;
- HTTP 非 2xx 抛错;
- 统一解析 JSON。
手动创建 job、轮询 job、自动读取 JSON、手动最终读取 JSON 都复用它,所以不会每层各自重置超时而无限延长总流程。
10.3 前端防竞态
假设:
- 自动检查 A 发出;
- 用户点击手动刷新;
- 手动任务完成并加载新 JSON B;
- 较老的 A 最后才返回。
如果没有保护,A 可能把页面覆盖回旧数据。
当前代码为每次数据读取递增 dashboardDataRequestSequence。响应返回时发现自己的 request id 已过期,就不再提交结果。
证据:cloud_usage_monitor/dashboard_assets/lumi_dashboard.js — refreshDashboardData()。
10.4 HTTP handler 为什么严格检查 raw target
SimpleHTTPRequestHandler 会对路径做一定解析或规范化。如果安全判断只看规范化后的 self.path,某些:
//api/refresh
http://host/api/refresh
带 query/fragment 的 target
可能在不同层被解释成相似路径。
本实现从 requestline 取原始 target,并要求 POST 完全匹配固定字符串。GET job 查询也拒绝 scheme、netloc、query、fragment、多段 job id 和非法字符。
这让“能启动作业的请求集合”尽量窄。
10.5 RefreshJobManager 为什么使用不可变快照
RefreshJob 是:
@dataclass(frozen=True, slots=True)
运行中和完成后不是修改同一个对象字段,而是用新快照替换字典里的值。这减少了读线程看到“部分字段刚更新、部分还没更新”的状态。
manager 的职责边界:
- 生成 job id;
- 保证一个 active manual job;
- 启动和回收线程;
- 把子进程结果转换成安全状态;
- 关闭时取消和等待;
- 限制终态记录数量。
它不负责 HTTP、采集、锁或 Dashboard 渲染。
10.6 有界子进程为什么复杂
简单的 subprocess.run(..., timeout=...) 可能只终止直接子进程,留下孙进程。
本项目的 scheduler 会再启动:
flowchart TD
Runner["run_after_review.py"] --> Children["单元测试 / 采集等后代进程"]
因此 run_bounded_process():
- Windows:先 suspended 创建根进程,加入 Job Object 后再 resume;
- Unix:创建新 session,以 process group 为单位发送信号;
- 独立 reader thread 持续排空 stdout,避免 pipe 写满造成死锁;
- 只保留输出尾部;
- 无论正常、超时还是取消,finally 都执行进程树清理。
这是整个分支技术复杂度最高的模块。
10.7 scheduler 如何串行
run_scheduler() 只计算一次 lock path,然后循环调用 run_cycle()。
run_cycle() 的关键顺序:
flowchart TD
Lock["acquire_file_lock"] --> Collect["collect"]
Collect --> Success{"collect 成功?"}
Success -->|"是"| Dashboard["dashboard"]
Success -->|"否"| Skip["跳过 dashboard"]
Dashboard --> Release["release lock"]
Skip --> Release
Release --> Sleep["sleep"]
因此:
- sleep 不阻塞手动刷新;
- collect 失败时不发布基于不完整周期的新 Dashboard;
- 定时与手动使用同一锁;
- 手动点击发生在定时任务中间时,会等待,不会插队并发执行。
10.8 监控服务如何组合
serve_monitoring._compose_service():
- 解析绝对 config 路径;
- 加载配置;
- 在 scheduler 锁内构建启动 Dashboard;
- 创建
RefreshJobManager; - 创建 Dashboard handler;
- 创建 admin server;
- 创建 Dashboard server。
主线程运行 Dashboard server,另一个非 daemon thread 运行 admin server。
这种组合保证按钮 API 和静态文件来自同一个 8780 server,而 8765 仍只提供 SQLite 管理功能。
10.9 Dashboard 文件如何发布
dashboard._stage_text_file() 负责一个文件的可靠 staging;write_lumi_dashboard_files() 负责两个文件的顺序。
异常处理刻意保留“主错误”:
- close 或临时文件清理失败不会覆盖最初的写入/替换异常;
- close 最多重试一次;
- staging 失败时尝试删除所有已知临时文件。
这些分支都有专门测试。
10.10 前端渲染为什么先克隆 body
如果直接在当前页面逐块更新,渲染到一半抛错,用户可能看到:
- 新 summary;
- 旧 chart;
- 被清空的筛选器;
- 已经失效的事件监听器。
renderNextDashboardData() 先克隆 body 并在克隆体上运行完整 renderDashboard()。只有所有步骤成功,才一次替换 body。
这是“页面级提交”:不是浏览器数据库事务,但能避免半更新 UI。
11. 测试与验证
11.1 实际运行的命令
在手动刷新功能的 linked worktree 中执行:
$BASELINE = "<功能基线分支>"
git diff --check "$BASELINE...HEAD"
git diff --cached --check
git diff --check
python -m unittest discover -s tests
结果:
Ran 155 tests in 40.579s
OK
三个 git diff --check 命令没有输出错误。
11.2 测试覆盖地图
| 文件 | 主要覆盖 |
|---|---|
tests/test_process_lock.py |
第二进程超时、释放后可重获、锁路径 |
tests/test_scheduler.py |
collect→dashboard、collect 失败跳过、锁释放、sleep 不持锁 |
tests/test_dashboard_refresh.py |
固定 argv、输出上限、超时/取消、进程树、job 生命周期、shutdown |
tests/test_dashboard_http.py |
API 202/200/400/403/404/503、同源、body、非法 target、静态文件 |
tests/test_serve_monitoring.py |
服务组合、启动锁、SIGTERM、资源关闭顺序 |
tests/test_dashboard.py |
按钮语义、deadline、schema、文件发布顺序、清理失败 |
tests/test_docker_packaging.py |
Docker 默认入口、文档端口安全、部署和维护顺序 |
11.3 现有测试的强项
- HTTP 解析的异常输入覆盖很细;
- 真正创建本地 HTTP server 检查状态码;
- 进程测试会生成子/孙进程,检查超时后 sentinel 不被写入;
- shutdown race 和线程启动失败有覆盖;
- 原子替换的多个失败点有覆盖;
- 跨进程文件锁有真实第二进程测试。
11.4 尚缺少的验证
代码事实:计划文档曾要求用受控 window.fetch 做真实浏览器行为验证,但当前 tests/test_dashboard.py 主要通过 HTML 解析和 JavaScript 源码字符串断言检查前端契约,仓库中未发现 Playwright/jsdom 驱动的点击与竞态测试。
因此尚应补充:
- 点击按钮后真实观察 POST、轮询和最终 GET;
- 自动旧响应晚于手动响应时,真实确认不会回滚;
- JSON schema 错误时,真实 DOM 保持不变;
- 渲染异常时,真实 DOM 保持不变;
- 键盘激活、焦点恢复和 reduced motion;
- 真 Lumi 环境的端到端验收;
- 真实反向代理/Ingress 下 Origin、Host 和超时行为;
- 真实 PVC 上跨容器/Pod 的文件锁行为。
12. 设计选择与取舍
12.1 复用 scheduler,而不是复制采集逻辑
优点:
- 手动与定时执行相同 collect/dashboard 顺序;
- 共用 review gate;
- 共用文件锁;
- 减少两条业务链逐渐不一致。
代价:
- 一次手动刷新会启动多层 Python 进程;
- review gate 在 collect 和 dashboard 前各跑一次;
- 延迟较高;
- 进程树管理更复杂。
12.2 202 + polling,而不是长连接
优点:
- 避免单个 POST 长时间占用连接;
- 对普通 HTTP server 和中间代理要求低;
- 刷新 job 状态清晰。
代价:
- 每秒产生一次 GET;
- job 状态只存在内存;
- 页面刷新或服务重启后没有恢复协议;
- 多副本下 job id 可能被请求到另一个副本。
12.3 文件锁,而不是数据库分布式锁
优点:
- 零新增依赖;
- 适合当前单机/共享本地存储架构;
- Windows 和 Linux 都可运行。
代价:
- 依赖底层共享文件系统的锁语义;
- 不提供排队可观测性;
- 不适合无共享盘的多副本;
- 不能自动覆盖绕过 scheduler 的直接命令。
12.4 JSON 先于 HTML
优点:
- 运行中的旧 HTML 已经具备读取新 JSON 的逻辑;
- JSON 是页面动态更新真正依赖的数据;
- HTML 失败后,自动检查仍可能恢复到新数据。
代价:
- 两文件可能短暂或持续版本不一致;
- 手动作业可能报告失败,但页面稍后仍更新;
- 运维人员不能把 job failed 简单等同于“没有任何新文件”。
12.5 严格前端 schema
优点:
- 无效 payload 不会破坏页面;
- 对后端字段变化形成显式契约;
- 强制关键列表每行结构一致。
代价:
- 后端新增必需字段或允许旧行缺字段时,要同步更新校验器;
- 一个无关列表行异常可能阻止整个页面更新;
- 手写校验代码较长。
13. 未完整接线与改进建议
13.1 当前已确认的未完整接线
VKE manifest 没有 scheduler
deploy/lumi-dashboard-vke.yaml 只定义默认监控服务容器。它能提供按钮和 API,但如果没有另行部署 scheduler,就没有周期更新。
仓库文档已经明确要求增加:
- 同镜像 scheduler sidecar;或
- 独立 scheduler Deployment;
- 两者共享 config、
work/和outputs/。
前端真实行为测试未落地
计划文档要求 behavioral browser test,但当前测试仍主要是源码契约断言。实现已存在,自动化验证层尚未完全匹配计划。
内网边界不在代码中
用户上下文说明 Dashboard 在内网,但仓库不能证明生产网络确实封闭。API 没有用户级鉴权。
13.2 改进建议
建议 A:补真实浏览器测试
优先级高,成本中等。
用 Playwright 启动本地 server,控制 job API 和 JSON 响应,验证完整点击、轮询、竞态、失败保留和键盘访问。
建议 B:为生产入口增加认证边界
如果 8780 可能越过可信内网,应在网关或应用层增加认证/授权。仅靠 Origin/Host 不能识别“谁有权触发一次真实采集”。
代价是需要接入 SSO、反向代理身份头或应用 session,并处理非浏览器运维客户端。
建议 C:给 job 状态增加可观测性
可以增加:
- job duration;
- lock wait duration;
- collect/dashboard 各自退出码;
- 最近一次成功时间;
- structured metrics。
注意不能把凭证、命令输出或敏感路径返回前端。
建议 D:减少重复门禁成本
可设计一个经过一次 review gate 后在同一受控进程内完成 collect + dashboard 的命令。
收益是降低延迟;代价是扩大单个入口职责,并需要重新证明失败隔离和维护命令兼容性。
建议 E:明确多副本策略
如果未来扩容:
- 单副本 + Recreate 是最简单的当前适配方式;
- 多副本需要 sticky routing 或共享 job store;
- 文件锁应在目标 PVC 类型上做真实验证;
- 更大规模可考虑数据库锁或队列,但会增加运维复杂度。
建议 F:让直接写命令显式选择锁
当前 run_after_review.py collect/dashboard/backfill 不参与 scheduler 锁,文档要求维护窗口手工停服务。
未来可为这些命令增加明确的 --acquire-scheduler-lock 模式,但必须避免嵌套调用造成同一进程链重复取锁。
14. 初学者代码阅读顺序
| 顺序 | 文件与符号 | 阅读目标 |
|---|---|---|
| 1 | cloud_usage_monitor/dashboard_assets/lumi_dashboard_template.html — manualRefreshButton |
看用户实际点击什么 |
| 2 | cloud_usage_monitor/dashboard_assets/lumi_dashboard.js — runManualRefresh() |
看浏览器主流程 |
| 3 | 同文件 — waitForManualRefresh() |
看 polling 协议 |
| 4 | 同文件 — isDashboardData() |
看前端如何拒绝坏数据 |
| 5 | 同文件 — renderNextDashboardData() |
看离屏渲染和页面提交 |
| 6 | cloud_usage_monitor/dashboard_http.py — make_dashboard_handler() |
看 API 路由和输入限制 |
| 7 | cloud_usage_monitor/dashboard_refresh.py — RefreshJobManager |
看 job 生命周期 |
| 8 | 同文件 — run_bounded_process() |
看进程树、输出和 timeout |
| 9 | scripts/scheduler.py — run_cycle() |
看手动与定时如何汇合 |
| 10 | cloud_usage_monitor/process_lock.py — acquire_file_lock() |
看跨进程互斥 |
| 11 | scripts/run_after_review.py — _run_collect() |
看采集入口 |
| 12 | cloud_usage_monitor/lumi.py — collect_lumi_usage() |
看 Lumi API 到 SQLite |
| 13 | scripts/run_after_review.py — _run_dashboard() |
看 Dashboard 生成入口 |
| 14 | cloud_usage_monitor/lumi_analytics.py — build_lumi_dashboard_payload() |
看 JSON 从哪里来 |
| 15 | cloud_usage_monitor/dashboard.py — write_lumi_dashboard_files() |
看文件发布 |
| 16 | scripts/serve_monitoring.py — _compose_service() |
最后回看整个服务如何组装 |
读测试时,建议按同样层次从小到大:
flowchart LR
ProcessLock["test_process_lock"] --> Scheduler["test_scheduler"]
Scheduler --> Refresh["test_dashboard_refresh"]
Refresh --> Http["test_dashboard_http"]
Http --> Service["test_serve_monitoring"]
Service --> Dashboard["test_dashboard"]
15. 变更文件索引
| Git 状态 | 路径 | 关键符号/内容 | 对应章节 |
|---|---|---|---|
| M | README.md |
定时/手动刷新、维护窗口、部署说明 | 8、13 |
| M | cloud_usage_monitor/dashboard.py |
_stage_text_file()、write_lumi_dashboard_files() |
7、9、10 |
| M | cloud_usage_monitor/dashboard_assets/lumi_dashboard.css |
.refresh-button、状态和无障碍动画 |
10 |
| M | cloud_usage_monitor/dashboard_assets/lumi_dashboard.js |
polling、schema、竞态、离屏渲染 | 5、6、7、10 |
| M | cloud_usage_monitor/dashboard_assets/lumi_dashboard_template.html |
manualRefreshButton、live region |
3、10 |
| A | cloud_usage_monitor/dashboard_http.py |
origin_matches_host()、DashboardHandler |
5、6、9、10 |
| A | cloud_usage_monitor/dashboard_refresh.py |
run_bounded_process()、RefreshJobManager |
4、5、6、9、10 |
| A | cloud_usage_monitor/process_lock.py |
refresh_lock_path()、acquire_file_lock() |
5、10 |
| M | docs/architecture.md |
新架构与长期入口 | 4、8 |
| M | docs/lumi-dashboard-rollout-checklist.md |
启停和维护顺序 | 8、13 |
| M | docs/openclaw-setup.md |
监控服务、scheduler 和 backfill | 8 |
| M | docs/superpowers/plans/2026-07-27-scheduler-manual-dashboard-refresh.md |
前端测试策略修订 | 11、13 |
| M | scripts/scheduler.py |
run_cycle()、锁和 --once |
5、6、10 |
| M | scripts/serve_monitoring.py |
_refresh_dashboard()、_compose_service()、shutdown |
4、6、9、10 |
| M | tests/test_dashboard.py |
前端契约、文件发布失败 | 11 |
| A | tests/test_dashboard_http.py |
API 和请求解析 | 11 |
| A | tests/test_dashboard_refresh.py |
进程与 job 生命周期 | 11 |
| M | tests/test_docker_packaging.py |
文档、端口和部署顺序 | 11 |
| A | tests/test_process_lock.py |
跨进程锁 | 11 |
| M | tests/test_scheduler.py |
周期顺序和锁释放 | 11 |
| A | tests/test_serve_monitoring.py |
服务组合和关闭 | 11 |
16. 未知项
| 未知 | 为什么仓库证据不足 | 需要什么证据 |
|---|---|---|
| 生产 8780 是否严格只在内网 | 用户上下文说“内网”,但网络配置不在完整仓库证据内 | 防火墙、Ingress、Service、NetworkPolicy 实际配置 |
| 是否有 SSO/网关鉴权 | 应用代码没有鉴权;可能由外部网关提供 | 网关路由和认证策略 |
| VKE 是否已经另行部署 scheduler | manifest 没有,但可能有外部平台配置 | 实际工作负载清单 |
| PVC 是否支持可靠跨 Pod 文件锁 | 本地测试只能证明本机文件系统 | 目标存储类型和双 Pod 锁测试 |
| Lumi 生产凭证是否只读 | 文档要求只读,代码无法检查 IAM 授权细节 | 云账号策略 |
| 真实采集耗时是否适合 30 分钟 | 仓库没有生产时延数据 | 运行日志和分位耗时 |
| 多副本时 job 查询是否粘滞 | 当前 manifest replicas: 1,未来拓扑未知 |
Service session affinity 或共享 job store 设计 |
| 浏览器实际行为是否通过端到端测试 | 当前未发现浏览器自动化测试 | Playwright/jsdom 测试结果 |
17. 总结与复习题
17.1 一句话总结
这个分支不是简单把文字换成按钮,而是把一个静态 Dashboard 扩展成“可由用户发起、可查询状态、受跨进程互斥保护、经过 review gate、能安全发布并在前端校验后提交”的完整刷新闭环。
17.2 最重要的五个结论
- 按钮触发的是固定
scheduler.py --once,不是任意命令。 - 手动与定时任务在 scheduler 文件锁处汇合。
- collect 成功后才执行 dashboard,但全链路不是一个事务。
- JSON 和 HTML 分别原子替换,两个文件之间仍有一致性窗口。
- 应用只实现同源请求限制,没有实现用户身份鉴权;内网安全依赖部署。
17.3 复习题
- 为什么
POST /api/refresh返回 202,而不是等 30 分钟再返回 200? - 线程锁和 scheduler 文件锁分别防止哪种并发?
- 为什么自动刷新不触发采集?
- collect 原始数据提交成功、聚合失败时,SQLite 和 Dashboard 分别可能处于什么状态?
- 为什么
dashboard-data.json要先于index.html替换? requestSequence如何防止旧自动响应覆盖手动结果?- 为什么同源校验不能代替身份鉴权?
- 为什么直接运行
run_after_review.py backfill前仍要进入维护窗口?
如果你能根据代码回答这些问题,就已经掌握了本功能的核心结构和主要风险边界。