chore(repo): initialize team collaboration repository
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
* text=auto
|
||||
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.html text eol=lf
|
||||
*.js text eol=lf
|
||||
*.css text eol=lf
|
||||
|
||||
*.docx binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.png binary
|
||||
*.webp binary
|
||||
@@ -0,0 +1,24 @@
|
||||
## 改动内容
|
||||
|
||||
-
|
||||
|
||||
## 原因与影响
|
||||
|
||||
-
|
||||
|
||||
## 验证
|
||||
|
||||
- [ ] `make test`
|
||||
- [ ] 相关演示或人工验收已完成
|
||||
- [ ] API、配置、数据结构或部署变化已同步文档
|
||||
|
||||
## 安全检查
|
||||
|
||||
- [ ] 未提交凭据、生产数据、数据库、日志或 Agent state
|
||||
- [ ] 未绕过安全门禁、事件写路径或状态转移约束
|
||||
- [ ] 危险动作仍保持默认拒绝
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
- 风险:
|
||||
- 回滚:
|
||||
@@ -0,0 +1,42 @@
|
||||
# This workflow becomes active only after an isolated Gitea Actions runner is
|
||||
# deliberately registered. No runner is installed on the production server.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
python-tests:
|
||||
name: Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.9", "3.12"]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: flashops
|
||||
env:
|
||||
PYTHONPATH: ${{ gitea.workspace }}/flashops/services/control-plane:${{ gitea.workspace }}/flashops/services/host-agent
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: flashops/services/control-plane/requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install -r services/control-plane/requirements.txt
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest services/control-plane/tests services/host-agent/tests -q
|
||||
|
||||
- name: Compile Python modules
|
||||
run: python -m compileall -q services/control-plane/flashops_control services/host-agent/flashops_agent
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Operating systems and editors
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Python environments and caches
|
||||
**/.venv/
|
||||
**/venv/
|
||||
**/__pycache__/
|
||||
**/.pytest_cache/
|
||||
**/.mypy_cache/
|
||||
**/.ruff_cache/
|
||||
**/*.egg-info/
|
||||
*.py[cod]
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# JavaScript dependencies and build output
|
||||
**/node_modules/
|
||||
**/.next/
|
||||
**/out/
|
||||
**/.turbo/
|
||||
|
||||
# FlashOps local runtime data
|
||||
flashops/var/
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.log
|
||||
agent-state.json
|
||||
|
||||
# Secrets and machine-local configuration
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
*.local.md
|
||||
*.env.local
|
||||
credentials.json
|
||||
secrets.json
|
||||
|
||||
# Temporary release and backup artifacts
|
||||
*.tmp
|
||||
*.bak
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*_backup/
|
||||
*_rollback/
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# STORAGE LABOS 团队协作指南
|
||||
|
||||
本文是团队日常 Git 操作的简明入口;完整工程约束见
|
||||
[ProjectManual.md](ProjectManual.md) 第 13 章。
|
||||
|
||||
## 1. 首次加入项目
|
||||
|
||||
公开仓库允许匿名克隆;需要提交代码、Issue 或 Pull Request 的成员先注册账号并由管理员批准:
|
||||
|
||||
```bash
|
||||
git clone https://git.imagebrewing.com/yuanshuai/storage-labos.git
|
||||
cd storage-labos/flashops
|
||||
make setup
|
||||
make test
|
||||
```
|
||||
|
||||
不要通过聊天工具传递压缩包继续开发;Git 仓库是代码和文档的唯一版本来源。
|
||||
|
||||
## 2. 分支规则
|
||||
|
||||
- `main`:始终保持测试通过和可发布,禁止直接开发或强制推送。
|
||||
- `feat/<short-name>`:功能开发。
|
||||
- `fix/<short-name>`:缺陷修复。
|
||||
- `docs/<short-name>`:纯文档变更。
|
||||
- `chore/<short-name>`:构建、依赖、部署与仓库维护。
|
||||
|
||||
一项工作一个分支;不要把无关功能、生产配置和大规模格式化混在同一分支。
|
||||
|
||||
```bash
|
||||
git switch main
|
||||
git pull --ff-only
|
||||
git switch -c feat/agent-step-lease
|
||||
```
|
||||
|
||||
## 3. 提交前质量门
|
||||
|
||||
```bash
|
||||
cd flashops
|
||||
make test
|
||||
make demo
|
||||
```
|
||||
|
||||
回到仓库根目录后,只暂存本次工作涉及的文件:
|
||||
|
||||
```bash
|
||||
git status -sb
|
||||
git add <明确的文件或目录>
|
||||
git diff --cached --stat
|
||||
git diff --cached --check
|
||||
```
|
||||
|
||||
混合工作区不要直接使用 `git add -A`,避免把别人的改动、数据库或临时文件带入提交。
|
||||
|
||||
## 4. 提交格式
|
||||
|
||||
使用 Conventional Commits:`type(scope): subject`。
|
||||
|
||||
| 类型 | 用途 | 示例 |
|
||||
|---|---|---|
|
||||
| `feat` | 新功能 | `feat(agent): add step lease polling` |
|
||||
| `fix` | 缺陷修复 | `fix(safety): reject stale host heartbeat` |
|
||||
| `docs` | 文档 | `docs(manual): add agent deployment guide` |
|
||||
| `test` | 测试 | `test(agent): cover token rotation` |
|
||||
| `refactor` | 不改变行为的重构 | `refactor(events): isolate projection writer` |
|
||||
| `perf` | 性能优化 | `perf(api): reduce timeline query cost` |
|
||||
| `chore` | 工具、依赖和部署 | `chore(ci): add Python test matrix` |
|
||||
|
||||
主题用祈使语气、简短明确;一次提交只表达一个完整意图。
|
||||
|
||||
## 5. 推送与 Pull Request
|
||||
|
||||
```bash
|
||||
git commit -m "feat(agent): add step lease polling"
|
||||
git push -u origin "$(git branch --show-current)"
|
||||
```
|
||||
|
||||
在 Gitea 创建 Pull Request,并写清:改了什么、为什么改、影响范围、验证结果和风险。
|
||||
至少一名团队成员评审且质量门通过后才能合并。当前 Gitea 未在生产服务器上启用自托管
|
||||
Runner,提交者必须在 PR 中附上本地测试结果;未来接入隔离 CI Runner 后再把自动检查设为
|
||||
必需。优先使用 Squash merge,PR 标题保持 Conventional Commit 格式;合并后删除远程功能分支。
|
||||
|
||||
评审者重点检查:
|
||||
|
||||
1. 安全门禁、状态机和事件写路径是否被绕过;
|
||||
2. 是否新增或修改测试;
|
||||
3. API、数据结构、配置或部署变化是否同步更新手册;
|
||||
4. 是否包含凭据、生产数据、日志、构建产物或大文件;
|
||||
5. 危险动作是否仍为默认拒绝。
|
||||
|
||||
## 6. 冲突与回退
|
||||
|
||||
功能分支同步 `main` 时使用团队统一方式;首阶段建议 rebase:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
仅允许对自己尚未合并的功能分支执行 `git push --force-with-lease`;严禁对 `main` 强推。
|
||||
已合并变更需要撤销时使用 `git revert <commit>`,不要改写共享历史。
|
||||
|
||||
## 7. 绝不能提交的内容
|
||||
|
||||
- `.env`、密码、Token、SSH 私钥、证书私钥;
|
||||
- `flashops/var/`、SQLite 数据库、Evidence Bundle 和日志;
|
||||
- `.venv/`、`node_modules/`、缓存和本机构建产物;
|
||||
- `~/.flashops/agent-state.json` 或任何 Agent 原始 bearer token;
|
||||
- 未脱敏的客户数据、设备序列号清单和生产备份。
|
||||
|
||||
如果凭据误入提交:立即停止推送、通知管理员轮换凭据,再清理历史;仅删除当前文件不等于
|
||||
从 Git 历史中删除。
|
||||
Binary file not shown.
+1606
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
# STORAGE LABOS / FlashOps
|
||||
|
||||
存储实验室固件回归测试的无人值守控制平面。当前仓库包含 FastAPI 控制面、独立 Host Agent、
|
||||
静态控制台、架构与部署文档,以及无需真实硬件即可运行的故障恢复演示。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd flashops
|
||||
make setup
|
||||
make test
|
||||
make demo
|
||||
make dev
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
|
||||
- 控制台:<http://localhost:8000/console/Dashboard.dc.html>
|
||||
- API 文档:<http://localhost:8000/docs>
|
||||
|
||||
## 团队协作
|
||||
|
||||
- Git 网页:<https://git.imagebrewing.com/yuanshuai/storage-labos>
|
||||
- 克隆地址:`https://git.imagebrewing.com/yuanshuai/storage-labos.git`
|
||||
- 项目完整说明:[ProjectManual.md](ProjectManual.md)
|
||||
- Git、分支、提交与 PR 规则:[CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- 后端与 Agent 状态:[flashops/README.md](flashops/README.md)
|
||||
|
||||
所有功能从独立分支开发,经本地测试和 Pull Request 评审后合并到 `main`。禁止把 `.env`、
|
||||
Token、私钥、数据库、Evidence Bundle、虚拟环境或本机 Agent 凭据提交到仓库。
|
||||
|
||||
## 当前边界
|
||||
|
||||
当前真实 Host Agent 已支持注册、鉴权、主机探针、心跳和在线状态老化;实际 Run 仍由内置
|
||||
模拟执行器完成。Step 租约、真实 NVMe 危险动作、真实 OOB、应用级 RBAC 尚未交付。
|
||||
Binary file not shown.
@@ -0,0 +1,360 @@
|
||||
# Storage LabOS · Regression Worker 顾问评估报告
|
||||
|
||||
**可行性分析 · 竞品调研 · 方案完善建议 · 总体规划**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 版本 | V1.0 |
|
||||
| 日期 | 2026-07-27 |
|
||||
| 评估对象 | 《Storage LabOS 固件回归测试值守机器人 完整方案 V1.0》 |
|
||||
| 调研方法 | 方案全文评审 + 四组并行联网调研(直接竞品 / 量产与中国本土 / 隐形竞品与 DIY / 市场与客户),关键结论均有一手来源交叉验证;查不到之处明确标注 |
|
||||
| 适用读者 | 两位联合创始人 |
|
||||
|
||||
---
|
||||
|
||||
## 0. 执行摘要
|
||||
|
||||
### 总体结论:有条件立项(Conditional Go)
|
||||
|
||||
方向对、切口对、时机对,但商业预期需要校准、若干关键判断需要修正。这不是一个"超大项目"的正确打开方式是先做大平台——恰恰相反,V1.0 里最值钱的判断(先做单工位值守机器人、不做测试仪、不碰协议合规)经过本次调研全部得到**证实和强化**。
|
||||
|
||||
### 六条核心判断
|
||||
|
||||
1. **差异化空白是真的。** 五家直接竞品(ULINK / OakGate / SANBlaze / Quarch / PyNVMe3)的公开资料中,"测试主机卡死自恢复、跨工具证据包、失败聚类、断点续跑、AI 判障"五项能力**均无成品级对应**;中国市场上也没有任何公司以独立软件产品形态销售"存储测试实验室编排系统"。空位真实存在——但窗口不会开三年(详见 §3.6)。
|
||||
|
||||
2. **差异化的表述必须修正。** DUT 侧的供电/复位/边带控制已是全行业标配(ULINK PSPA、SANBlaze 每盘位电控、Quarch 全系)。对外把"带外自救"讲成"给盘断电重启"会被当成已有功能。正确锚点:**测试主机失联的检测 → 分级恢复 → 从检查点续跑的闭环**——这在"通用主机 + 软件工具"型实验室(正是国内主控/模组厂的典型形态)价值最大。
|
||||
|
||||
3. **自组树莓派带外控制器在 2026 年是负资产。** JetKVM($69)、Sipeed NanoKVM($20 起)、GL.iNet Comet、PiKVM(HTTP API/IPMI/Redfish/ATX 控制齐全)已把"看画面 + 键鼠 + 重启 + 进 BIOS"做成商品。正确姿势:**兼容任意商品 KVM/PDU 作执行器**,把差异化全部押在商品盒子没有的四层软件——心跳判定语义、分级恢复策略引擎、与工作流状态联动的续跑、死机瞬间的证据留存。这四层目前没有任何现成开源实现。
|
||||
|
||||
4. **市场真实但是利基,融资策略要据此校准。** 可数目标客户:中国约 30-40 家、全球 60-90 家;按工位算 SAM:中国约 400-900 工位(对应年软件盘子约 ¥1,000-3,000 万),全球约 1,200-2,200 工位。这撑得起一家 10-50 人、年营收数千万级的专精产品公司,**撑不起典型 VC 规模叙事**——更适合天使 + 产业资本/设备商战投/专精特新路线,或 bootstrap。扩大盘子的路径是后期向邻近垂直(服务器 BMC/固件测试、GPU 老化、存储系统)延伸。
|
||||
|
||||
5. **时机是近五年最好的。** 存储超级周期:2024 年中国企业级 SSD 市场同比 +187.9%(IDC),2026Q1 企业级 SSD 价格环比 +80-100%,供需缺口预计持续到 2027 年底。客户在扩产、招测试人、买设备——测试产能是显性瓶颈,正是卖"无人值守"的窗口。反面:客户忙于出货,POC 时间难约,交付必须低打扰。
|
||||
|
||||
6. **"LabOS" 这个名字必须改。** 软件类目全球至少两个在营同名产品(labos.co 医疗 LIS、增材制造 LabOS),中国第 9 类商标已被注册且正处"撤三"争夺。主品牌应造独特词,"Storage LabOS"只可作描述性副题。
|
||||
|
||||
### 立项的四个前置条件
|
||||
|
||||
0. **领域合伙人("同学")或等效 SOP 来源在 30 天内书面落定**——注意:截至本报告日,"同学"只是意向对象,SOP、样品、客户引荐都尚未承诺。在此落定之前不启动产品工程(允许一个例外:2-4 周时间盒的零售盘技术样机,用作招募道具,且必须与合伙谈判、客户访谈并行推进——见 Phase 1a);
|
||||
1. 30 天内完成 ≥3 家公司、5-10 人访谈,拿到 ≥2 套真实 SOP(防止过拟合单一公司样本);
|
||||
2. 首个 POC 客户书面意向(可免费 POC + 验收付费条款);
|
||||
3. 首客户工具链清点完成——每个工具标注 CLI / API / GUI-only,GUI-only 刷写工具风险有预案。
|
||||
|
||||
### 三个 Kill Criteria
|
||||
|
||||
1. 访谈中"值守/复现"痛点排不进客户前三;
|
||||
2. POC 8 周内无法稳定完成 20 循环 A/B,或注入故障恢复成功率 <60%;
|
||||
3. 第二个客户起连接器复用率 <40%(项目制泥潭信号)。
|
||||
|
||||
### 本周就该做的五件事
|
||||
|
||||
1. 和"同学"把合作谈起来——先做**最小承诺测试**:本周约谈,请对方给一套脱敏 SOP(或口头带你走一遍流程)+ 30 分钟讲实验室值守现状。这一步对他成本近乎为零;若连这都推进不了,合伙就是假设而非事实,趁早启动替代路径(见 §4 Phase 0);
|
||||
2. 约首客户测试主管做工具链清点(CLI/GUI 清单决定 MVP 技术路线);
|
||||
3. 联系上海森弗(Saniffer)/深圳锐测(Reetest)询价 SANBlaze/ULINK/Quarch 在华价格(补定价锚点,顺带探渠道合作意向);
|
||||
4. 启动新名字的商标检索(9/42 类 + 美国);
|
||||
5. FMS 2026 是 8 月 4-6 日(圣克拉拉,距今一周)——来不及参展,若有美国朋友可代为观展收集竞品资料;国内主战场定为 2027 年 3 月深圳 CFMS|MemoryS。
|
||||
|
||||
---
|
||||
|
||||
## 1. 对 V1.0 方案的评价
|
||||
|
||||
### 1.1 做得好的地方(保留并强化)
|
||||
|
||||
1. **战略克制**——"不做什么"清单(不做协议合规套件、不做 Gen5/6 高速背板、不做 EDA、AI 不碰危险命令)是全文档最值钱的部分,本次调研全部证实其正确性;
|
||||
2. **北极星指标选择正确**——UCR + 释放工时,且配了"基础设施误报率"这种反直觉但关键的护栏指标;
|
||||
3. **失败状态模型**(INFRA_* 与 DUT_* 分离)是真正的领域洞察,多数自研脚本死在这里;
|
||||
4. **Evidence Bundle 具体到目录结构**,不是空话;
|
||||
5. **竞品策略正确**——把竞品连接器化而不是对抗,调研证实 OakGate/SANBlaze 的 REST/Python API 完整,技术上可行;
|
||||
6. **决策门设计**——每一步扩张都有前置证据要求;
|
||||
7. **风险清单诚实**——自己点破了"Jenkins 套壳"和"定制泥潭"两大死法。
|
||||
|
||||
### 1.2 关键缺口清单
|
||||
|
||||
**P0(不补就无法立项/融资/启动)**
|
||||
|
||||
| # | 缺口 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | 全文没有钱、没有时间 | 没有一个货币数字、一个时间盒。缺:POC 预算、18 个月资金需求、定价、收入预测、阶段时长。本报告 §5 已补一版供修订 |
|
||||
| 2 | 没有市场规模 | "中型 SSD 团队"有多少家可数客户没有回答。本报告 §2.2 已补 |
|
||||
| 3 | Windows GUI 工具风险被系统性低估 | MVP 排除"视觉操作",但大量厂商刷写工具(MPTool 类)是 GUI-only。若首客户刷写工具无 CLI,MVP 第一步就被卡死。预案:(a) 要求客户提供 CLI 版;(b) pywinauto/UIA 自动化作受控例外;(c) 换场景 |
|
||||
| 4 | 缺 Phase 0 客户发现 | 立项动作直接从"拿 SOP 开发"开始;应先访谈 ≥3 家、拿 ≥2 套 SOP 再写代码 |
|
||||
| 5 | 命名冲突 | "LabOS"必须改(详见 §2.2 市场部分与执行摘要) |
|
||||
|
||||
**P1(POC 前后必须补)**
|
||||
|
||||
| # | 缺口 | 说明 |
|
||||
|---|---|---|
|
||||
| 6 | 硬件策略过时 | 树莓派自组方案应降级为"兼容的执行器之一",改为适配商品 KVM/智能 PDU(JetKVM/NanoKVM/PiKVM 等),省下的精力投入判定/策略软件层 |
|
||||
| 7 | 测试主机镜像/队伍管理缺章 | hibernate/resume 在真实 Windows 上有大量坑(快速启动、驱动、唤醒源、BitLocker、Windows Update 干扰)。需要 golden image + 组策略固化 + 主机健康自检方案——这本身就是护城河 know-how |
|
||||
| 8 | Agent 自身可靠性 | "Agent 崩溃不丢证据"有验收,但没有"谁来救 Agent"的机制设计(本地看门狗、开机自启、双进程互监) |
|
||||
| 9 | 法务与责任 | DUT 损坏责任条款(行业范式:破坏性测试知情免责 + 过错赔偿上限=合同额)、适配私有工具的 IP 约定、开源许可合规、POC 合同模板 |
|
||||
| 10 | ROI 测量协议 | "人工执行一遍并记录"应升格为正式基线测量协议,写进 POC 合同,否则"显著减少人工"无法被客户财务认可 |
|
||||
| 11 | 交付成本模型 | 每个 POC 人天预算、连接器复用率目标(>60%)——没有度量,"项目制陷阱"条款无法落地 |
|
||||
| 12 | 竞品名单五处补遗 | 通用编排层(NI TestStand/OpenTAP/OpenHTF+TofuPilot)、量产设备商、中国本土(鸾起/置富)、测试服务商、商品 KVM。详见 §3 |
|
||||
|
||||
**P2(产品化阶段补)**
|
||||
|
||||
13. 开源策略未讨论(连接器 SDK/Agent 是否开源,各有利弊);
|
||||
14. 多客户私有化部署矩阵——发布通道/LTS 政策要早定,否则升级成本爆炸;
|
||||
15. UCR 可被刷(任务切小刷高),需定义"触碰"口径,以"每百小时介入次数"为主指标;
|
||||
16. 文档应拆三份:内部战略版(现有)、客户 pitch 版、投资人版(加财务)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 可行性分析
|
||||
|
||||
### 2.1 技术可行性:高(4/5)
|
||||
|
||||
**结论:无科研级风险,全部是工程问题;真正的风险不是"能不能做出来",而是"在客户杂乱环境里稳不稳"。**
|
||||
|
||||
- 所有组件均有成熟先例:商品 KVM/PDU 执行器 + 状态机 + 对象存储 + 采集管道。开源积木充足——labgrid 已解决电源/串口/资源互斥抽象(支持 30+ 种 PDU),Temporal 语义上完美匹配持久工作流(官方支持"分钟到数年"的执行)。
|
||||
- **真正难写对的三层**(也是壁垒所在):
|
||||
1. **判定层**:把"卡死"判对。误判一次 = 一台跑到第 60 小时的 3 天 retention 测试被无辜断电,比不自动化更糟。区分主机挂/盘挂/进程挂/网络抖,需要按机型、固件、测试类型积累的启发式阈值库——没有任何开源件自带;
|
||||
2. **物理状态与代码状态的对账**:durable engine 只保证代码状态持久,不保证物理状态一致(工作流认为在第 7 步,真实世界里主机已重启过、固件槽位已切换)。断电这类不可幂等动作的护栏 + 恢复对账,是全项目最难写对的代码;
|
||||
3. **取证顺序**:死机瞬间抓什么、按什么顺序抓(取证与快速恢复互相冲突)、怎么和固件版本/步骤对齐。
|
||||
- Windows 生态脏细节(GUI 工具、休眠唤醒、驱动、更新)是最大的工程摩擦源,必须用 golden image + 镜像管理对冲。
|
||||
- POC 3-4 个月时间盒内可验证。
|
||||
|
||||
### 2.2 市场可行性:中(3/5)——真实、在扩张、但是利基
|
||||
|
||||
**可数客户(自上而下)**
|
||||
|
||||
| 客户类别 | 全球 | 中国大陆 |
|
||||
|---|---|---|
|
||||
| SSD 主控厂 | 独立主控 ~10 家 + 原厂自研 6 家 | 16-20 家(联芸、得一微、英韧、忆芯、华澜微、国科微、大普微、华存、兆芯、特纳飞……) |
|
||||
| 企业级/DC SSD | 7 家原厂系 | 12-15 家有独立固件/验证团队(忆联、忆恒创源、大普微、得瑞、ScaleFlux、泽石、芯盛……) |
|
||||
| 模组厂(有固件介入能力) | 全球有名有姓 SSD 制造商 235 家(多数无固件能力) | 30-50 家(江波龙、佰维、德明利、朗科、金泰克、七彩虹、嘉合劲威、宏旺、记忆科技……) |
|
||||
| 工业/车规存储 | 25-35 家(Swissbit、宜鼎、宇瞻、ATP、Virtium……) | 若干家 + 大厂工规线 |
|
||||
| Hyperscaler qual 团队 | 6-8 家海外 + 阿里/腾讯/字节/百度 | 不是首批客户(自研体系),但 OCP 规范是需求放大器:被验收方(SSD 厂)要买工具自证合规 |
|
||||
|
||||
**中间层估算**(5-50 工位、有回归压力、无成熟自研平台):**中国约 30-40 家,全球约 60-90 家**;按每家 8-25 个可付费工位 → **SAM:中国 400-900 工位,全球 1,200-2,200 工位**。按 §2.3 定价折算,中国年软件盘子约 ¥1,000-3,000 万——**撑得起专精产品公司,撑不起典型 VC 故事**。上行空间:兼容性测试柜(方向 4)扩大单客户工位数、邻近垂直(服务器固件/BMC 测试、GPU 老化)、海外工业存储。
|
||||
|
||||
**痛点证据链(本次调研实证)**
|
||||
- 联芸(猎聘 JD):"固件测试自动化开发、持续集成建设、失败用例分析定位"——这些岗位就是编排层缺失的人肉补丁;
|
||||
- 江波龙 JD:"回归测试确保固件健康度";大普微 JD:"开发测试机台程序"(自建平台直接证据);
|
||||
- 江波龙/佰维/记忆科技/晶存的测试装置专利全部强调"提高自动化程度/并行规模"——每家都在重复造执行层轮子,编排层仍靠人;
|
||||
- 置富科技已用"一键启动、全程无人值守"营销测试柜——无人值守是被客户认可的卖点。
|
||||
|
||||
**行业周期**:2024 中国企业级 SSD 市场 $62.5 亿(+187.9%,IDC);2026Q1 企业级 SSD 价格环比 +80-100%,缺口预计到 2027 年底;江波龙 2025 营收 227.66 亿、佰维净利 +429%、德明利营收破百亿、大普微 IPO 推进中。**客户数量与测试工位都在增加,测试人手是瓶颈——窗口期成立。**
|
||||
|
||||
### 2.3 商业可行性:中(3/5)
|
||||
|
||||
**定价建议(有推理链)**
|
||||
- 客户价值 = 省 0.5-1 个测试工程师(深圳/北京 SSD 测试工程师 15-25K×14 薪,企业年成本 ¥28-47 万)+ 夜间/周末设备利用率提升 30-50%(对 ¥300-1,200 万测试资产即 ¥18-60 万/年等效产能)→ 单客户年价值 ¥30-80 万;
|
||||
- 定价取价值 20-35%:**软件 ¥6,000-12,000/工位/年**(30 工位以上阶梯降)+ **POC 交付服务一次性 ¥5-15 万** + **带外控制盒 ¥3,000-8,000/工位**;
|
||||
- 典型 20 工位客户:首年合同 ¥25-40 万,续年 ¥15-25 万 ≈ 0.5-1 名工程师人力成本——ROI 一页纸讲得清;海外工业客户 $2,000-4,000/工位/年;
|
||||
- **两个定价纪律**:绝不落进 $50/席位的 SaaS 锚点(TofuPilot 教训);年费必须绑定设备商不做的三样东西——跨厂商编排、客户 SOP 资产化、回归结果数据库——否则会被"硬件送软件"(Quarch 明示 API 无年费、SANBlaze 自带 OCP 脚本)替代;
|
||||
- 行业公开锚点:PyNVMe3 软件 $59,000/年(不限部署)、NI 软件合同均值 ~$19k/年、DIY 三年 TCO >$500k(硅谷人力)/约 ¥150 万(国内人力)——"买 vs 造"话术的核心数字。
|
||||
|
||||
**销售周期与合同形态**:POC 1-3 个月 + 商务 1-6 个月,总周期按 3-9 个月规划;决策链 = 测试经理发起 → 固件总监批预算 → 安全审查私有化 → 采购走谈判/招标;准备"订阅"与"一年期服务合同"双合同形态(国企/上市公司报销体系)。
|
||||
|
||||
**生死度量**:客户现场 UCR 与每百小时介入次数(产品价值)、连接器复用率 >60%(产品公司 vs 外包公司分界)、每 POC 交付人天(服务毛利)。
|
||||
|
||||
### 2.4 团队可行性:条件分(落定前 2/5,落定后 3/5)
|
||||
|
||||
- **最大的团队事实:合伙尚未落定。**「同学」目前只是意向对象,SOP、样品工位、客户引荐都还不是承诺。在其角色落定之前,本项目实际上是**单人项目**——单人做 to-B 硬科技(无领域背景、无客户通路)不成立,因此团队可行性在落定前按 2/5 看待,这是当前第一优先级和唯一阻塞项;
|
||||
- 若落定,两人组合(产品/全栈/AI + 存储领域/客户/资金)是经典配置,覆盖 POC 阶段,恢复 3/5;
|
||||
- 推进方式:用最小承诺测试逐级升级(见 §4 Phase 0 第 1 条),四周内走到"联合创始人 / 顾问 / 客户 champion"三选一,不允许停留在模糊状态。三种角色都能支撑项目启动,但对应不同的股权与替代招聘方案;
|
||||
- **硬缺口**:全职验证工程师(Phase 1 可兼职借调,Phase 2 必须全职)、嵌入式工程师(改用商品 KVM 后需求后移,Phase 3 再考虑);
|
||||
- 落定后仍要管理的结构性风险:既是渠道又可能是首客户,依赖与冲突并存——股权、竞业与客户关系归属要在书面协议里写清。
|
||||
|
||||
### 2.5 财务可行性(框架估算,按实际薪资修正)
|
||||
|
||||
| 阶段 | 时间 | 团队 | 现金消耗(累计) | 里程碑 |
|
||||
|---|---|---|---|---|
|
||||
| Phase 0 排雷 | 第 1 个月 | 创始人 2 | ≈0 | Go/No-go 备忘录 |
|
||||
| Phase 1 POC | 第 2-5 月 | 2 + 兼职 | 硬件 2-5 万;累计 30-60 万 | 7×24 无人值守 Demo |
|
||||
| Phase 2 付费试点 | 第 5-9 月 | 4-5 人 | 累计 150-250 万 | 首笔收入(POC ¥5-15 万×1-2 单) |
|
||||
| Phase 3 产品化 | 第 10-18 月 | 5-8 人 | 累计 400-600 万 | 3-5 客户、ARR ¥100-200 万 |
|
||||
|
||||
融资建议:**天使/产业资本 300-500 万**(代理商、模组厂 CVC、地方产业基金都比纯财务 VC 匹配),或自有资金 + POC 收入滚动。叙事对标存在但要诚实:硬件测试数据平台 Nominal 2026 年 3 月达 $10 亿估值(10 个月融 $155M)、AI SRE 公司 Resolve.ai 估值 $15 亿——资本已验证"硬件测试"与"AI 根因调查"两个故事,而**"控制平面"(把机器救活继续跑)无人占位**;但你的可数市场决定了讲法应是"利基里的绝对领先 + 邻近垂直扩张",不是平台故事。
|
||||
|
||||
### 2.6 总体结论
|
||||
|
||||
**有条件立项。** 前置条件与 Kill Criteria 见执行摘要。最大的三个风险按序:**领域合伙人未落定(当前唯一的阻塞项,条件 0)** → 市场厚度(中间层客户数量有限)→ 项目制泥潭(连接器复用率)。技术不在前三。
|
||||
|
||||
---
|
||||
|
||||
## 3. 竞品调研(四组联网调研合并)
|
||||
|
||||
### 3.1 五家直接竞品:逐项核实结果
|
||||
|
||||
**五项差异化主张 vs 竞品现状**(公开资料核实,2026-07)
|
||||
|
||||
| 能力 | ULINK | OakGate | SANBlaze | Quarch | PyNVMe3 | 判定 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 测试主机卡死自恢复 | 无 | 架构性缓解¹ | 架构性缓解¹ | 有零件无闭环² | 无 | **真空白** |
|
||||
| 跨工具统一证据包 | 无 | 单生态强 | 单生态 | 功耗+边带同轴 | 单生态 | **真空白** |
|
||||
| 失败聚类去重 | 未见 | 未见 | 未见 | 无 | 未见 | **全行业无人交付** |
|
||||
| 断点续跑 | 未见 | 无 checkpoint | 未见 | 无 | pytest 语义,无 | **真空白** |
|
||||
| AI 辅助判障 | 无³ | 无 | 无 | 无 | 无⁴ | **最干净的差异化** |
|
||||
|
||||
¹ 专用 Linux appliance 即测试主机,环境受控、客户端分离——痛点被架构降低而非功能解决;² Quarch 模块走独立 LAN/USB 通道,是自救的现成执行器,但不提供"检测→恢复→续跑"编排;³ ULINK 的 AI(DA SmartQuest)面向在役硬盘故障预测,与实验室无关;⁴ PyNVMe3 的"LLM workload"是用 AI 负载测盘,不是 AI 分析失败。
|
||||
|
||||
**各家快照与威胁评级**
|
||||
|
||||
| 竞品 | 近况(2025-2026) | 威胁 | 合作 | 策略 |
|
||||
|---|---|---|---|---|
|
||||
| ULINK DriveMaster | DM10 v10.1.1900、PSPA+ Gen5、无 Gen6 路线、无公开 API、Windows GUI 封闭 | 2/5 | 1/5 | TCG 认证细分互补;GUI 封闭反而难集成 |
|
||||
| Teledyne LeCroy OakGate | PCIe 6.0 方案 2025-08 可订购;REST/Python/CLI/C API 完整;Automation Tool 单生态编排 | 4/5 | 2/5 | 视为高价值执行器,用其 API 接入 |
|
||||
| SANBlaze | SBExpress-RM6 业界首个 Gen6 系统(2025-10);OCP 2.6 + Azure 深度绑定;REST/XML/Python | 4/5 | 3/5 | 同上;其 OCP 地位是"被验收方买工具"逻辑的最好注脚 |
|
||||
| Quarch | Gen6 全家桶 + Aon 桌面一体机;quarchpy 全线 Python;被 SerialTek/PyNVMe3 集成的先例 | 1/5 | **5/5** | **天然盟友,尽早建集成/转售关系** |
|
||||
| PyNVMe3 / GYTech | $59k/年不限部署 + F6 12 盘服务器 $39k(公开价);已建实验室层雏形(槽位编排、Grafana 监控);中国主控/原厂渗透深 | **4/5(对中国 5/5)** | 3/5 | **最大战略威胁**:同构(Python/CI/年费/软件优先),向上一步就是编排层。既是最优先集成对象,也是最可能相撞者;注意其 EULA 禁止用于开发竞品,商务需切割 |
|
||||
|
||||
关键修正:**最需警惕的不是"谁已经做了",而是"谁最容易补上"**——OakGate/SANBlaze 给自家 appliance 加 watchdog 的工程成本不高,但商业惯性(卖盒子、单生态)使其大概率不做跨厂商编排。LabOS 应死守的定位:**异构工具之上的中立层**。
|
||||
|
||||
### 3.2 V1.0 完全遗漏的五类竞品
|
||||
|
||||
**① 通用测试编排层**(能力不重叠,但销售时会被拿来比价)
|
||||
- **NI TestStand + SystemLink**(现属 Emerson):行业标准测试序列器;"单站、有人值守、产线节拍"范式,无主机自救概念。价格参考:NI 软件合同 $3k-40k/年(均值 ~$19k);
|
||||
- **Keysight OpenTAP**:开源序列引擎,生态以自家射频仪器为中心,无存储案例——证明"水平化测试编排平台长不出垂直深度",反向支持做垂直;
|
||||
- **OpenHTF(Google 开源)+ TofuPilot(瑞士,2022)**:画像最像的创业公司——OSS 楔子 + SaaS 分析层,$50/用户/月 + $50/站/月。但做产线短测试(分钟级、良率视角),不做编排/恢复/取证。它证明了模式,也划出了**必须避开的低价锚点**;
|
||||
- **Nominal**($1B 估值,Founders Fund/Sequoia,10 个月融 $155M)与 **Sift**(ex-SpaceX,$42M B 轮):硬件测试数据平台已被资本验证——但全部是**数据平面**(遥测进、分析出),**控制平面无人占位**。这是融资叙事的现成对标,也是远期威胁(Nominal Connect 已做边缘测试执行,切入存储只缺动机);
|
||||
- Averna / Marvin ATEasy:测试集成商——"大厂不自研时会雇的人",应加入竞品清单。
|
||||
|
||||
**② 量产/可靠性测试设备商**(离研发回归很远,但要主动切割叙事)
|
||||
- Neosem(Gen5 测试机,供三星/美光/英特尔;中国独家代理=深圳锐伺)、EXICON(三星老化机主力,单批订单 1.65 亿元人民币)、Advantest MPT3000(SLT,百万级整机)、Teradyne(芯片级 ATE + HDD 产线)、Chroma(已淡出 SSD 测试);
|
||||
- **"存储测试国产化"的资本叙事(精智达 ~13 亿订单、悦芯 12 亿融资、武汉精鸿长存中标)全部在芯片级 ATE**——与盘级固件回归是两个市场,给投资人讲故事必须主动切割,否则被错误对标。
|
||||
|
||||
**③ 中国本土两家必须写进方案的公司**
|
||||
- **鸾起科技**(苏州,2022;创始人岑彪 ex-AMD/LSI/CNEX,《深入浅出 SSD》作者之一;2026-01 中科创星领投 B 轮近亿元):"国产 SANBlaze"生态位——eBird 研发端测试平台(Gen5,协议级到系统级,Turnkey 用例包)+ Phoenix 量产 RDT。忆恒创源已导入,与联想/长江存储有合作。**公开资料未见实验室编排产品**——它既是最可能向编排层延伸的本土竞对,也是最合理的集成/渠道伙伴(eBird 可作被编排执行器);
|
||||
- **置富科技**(深圳,北交所):2025-04 发布五款宽温智能测试设备(128-256 盘位),营销话术就是"开放式工单配置平台 + 一键启动、全程无人值守"——**"无人值守"心智已被抢注**,但其软件绑定自家柜体,反而验证了"硬件无关的编排软件"差异化成立。最需盯防的邻位竞对。
|
||||
|
||||
**④ 第三方测试服务商 = 互补而非替代**
|
||||
- 百佳泰 Allion(SSD 顾问/Workload 采集/竞品对标)、GRL(PCIe 官方合规认证)、宜特 iST(芯片可靠性)、UNH-IOL(NVMe 合规权威,SANBlaze 是其执行平台)、赛宝/中析(送样检测,用的正是 DriveMaster);
|
||||
- 外包适合低频里程碑事件(认证/对标/摸底);高频固件回归外发有**保密 + 周转**两大硬伤。真正的替代品是"多招两个测试工程师值夜班 + 自写脚本"。
|
||||
|
||||
**⑤ 商品 KVM / OOB 硬件浪潮**(对 V1.0 硬件方案的直接修正)
|
||||
|
||||
| 产品 | 价格 | ATX 控制 | 可编程性 |
|
||||
|---|---|---|---|
|
||||
| JetKVM | $69(众筹 $400 万+/3 万人) | 官方 ATX 扩展板 | Go 后端开源 |
|
||||
| Sipeed NanoKVM / Pro | $20 起 | 有 ATX 版 | 开源;Pro 已带**多节点批量管理 + AI 助手** |
|
||||
| GL.iNet Comet | PoE 版 $109.99;Comet X 一拖四 | 官方 ATX 板 | 集中管理 |
|
||||
| PiKVM | 数百美元级 | 官方 ATX 板 | HTTP API、**IPMI & Redfish**、Prometheus、宏 |
|
||||
|
||||
服务器场景 OOB 本是标配(IPMI/BMC、Redfish、Intel AMT、智能 PDU)。**结论:硬件本身零差异化;商品盒子没有且短期不会有的是四层软件**——心跳判定语义、分级恢复策略引擎(升级/退避/冷却/熔断 + 恢复前先取证)、与工作流状态联动、证据留存。警惕商品盒子从下往上长(NanoKVM Pro 已加批量管理和 AI 助手)。
|
||||
|
||||
### 3.3 DIY 威胁的定量刻画(最重要的竞争事实)
|
||||
|
||||
> **一个 2-3 人的内部团队,3-6 个月能拼出"能演示的 80%";拼不出"连续 90 天无人值守的 80%"。产品的全部价值在后者。**
|
||||
|
||||
- 拼得出(这就是每家 SSD 厂都有 Jenkins 脚本坟场的原因——DIY 不是假想敌,是已发生的现实):商品 KVM + 智能 PDU + labgrid(1-2 周)→ Temporal 包 SOP(1-2 月)→ pytest + 正则聚类 + 模板报告(再 1-2 月);
|
||||
- 拼不出的 20%(DIY 团队在第 6-18 个月撞上):
|
||||
1. 判定层误判致死(一次误断电毁掉跑到第 60 小时的 retention 测试)——靠 incident 数量堆出来的阈值库;
|
||||
2. 物理状态与代码状态的对账 + 不可幂等动作护栏——全项目最难写对的代码;
|
||||
3. Evidence Bundle 的领域深度(抓什么、什么顺序、怎么对齐);
|
||||
4. 失败聚类冷启动(算法几周,可信聚类需几个季度的带标注语料);
|
||||
5. 机群运维长尾(USB 重枚举、EDID、串口猝死——Linaro/Pengutronix 为此养全职团队);
|
||||
- DIY 真实 TCO:2-3 人×6 月建设 + 永久 ~1 FTE 维护 + 关键人一走就腐烂;三年 TCO 国内人力约 ¥150 万、硅谷 >$500k——**这是定价天花板依据和"买 vs 造"销售话术的核心数字**。
|
||||
|
||||
### 3.4 可防御空间排序(护城河修正版)
|
||||
|
||||
1. **机型级判定/恢复知识库**——唯一随时间复利、DIY 团队永远只有单厂样本的资产;
|
||||
2. **Evidence Bundle schema 与取证顺序**做成事实标准(对标 OCP telemetry 生态,配合"报告可直接交给下游客户审计"叙事);
|
||||
3. **每客户私有失败语料 + 共享解析器/模型架构**(行业保密性强,跨客户共享语料几乎不可能——V1.0 §9.2 数据飞轮的脱敏设计是对的,但护城河强度要打七折);
|
||||
4. **"无人值守"作为 SLA 卖**——买的是夜班消失,不是软件席位;
|
||||
5. **不可防御、不要投入**:OOB 硬件、KVM 软件、工作流引擎、测试框架、通用聚类算法——全部用现成的。
|
||||
|
||||
### 3.5 窗口期判断
|
||||
|
||||
三面夹击的时间表:上有 Nominal($155M 弹药)/TofuPilot 下探,下有 NanoKVM Pro/PiKVM 往批量管理和 AI 助手上长,侧有 PyNVMe3/鸾起/置富从执行层向编排爬。**窗口真实存在,但按 18-24 个月规划,不按三年规划**——这也是 Phase 1/2 必须严格时间盒的原因。
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体规划(重排路线图)
|
||||
|
||||
核心修正:V1.0 阶段 A-E 只有内容没有时间和钱。以下为带时间盒、预算、验收和 Kill 条件的版本。
|
||||
|
||||
### Phase 0 — 合伙落定、客户发现与排雷(第 1-4 周,现金 ≈0)
|
||||
|
||||
写代码之前完成(第 1 条是其余一切的前提):
|
||||
1. **谈定领域合伙人(真正的第 0 步——目前"同学"尚未承诺任何事)**。用升级阶梯推进,避免一上来就要"辞职入伙"这种大到只能拖延的请求:
|
||||
- ① 最小承诺测试(本周,对方成本≈0):一套脱敏 SOP 或口头走一遍流程 + 30 分钟值守现状访谈。可以拿本报告当谈判工具——竞品与市场数据是现成的"这事值得干"论证;
|
||||
- ② 第二级(第 2-4 周):2-3 个访谈引荐 + 联合做一次人工基线测量;
|
||||
- ③ 第三级(POC 启动前):角色三选一并书面化——联合创始人(股权 + 时间承诺)/ 顾问(小比例顾问股 + 明确交付清单)/ 首客户 champion(不占股,客户关系)。三种都能支撑启动,但必须选一个;
|
||||
- **决断时限**:四周内走不到第②级,即启动替代路径——付费聘请在职/离职 SSD 验证工程师做顾问(§2.2 的 JD 调研证明这个人群在深圳/上海可及),并经 ssdfans 社区、CFMS 人脉、森弗/锐测代理商冷启动接触 2-3 家中型厂谈 design partner;
|
||||
2. ≥3 家公司、5-10 人分层访谈(用 V1.0 §11.5 清单:固件工程师/验证工程师/实验室负责人);
|
||||
3. 拿到 ≥2 套真实 SOP(合伙人来源 + ≥1 外部);
|
||||
4. 人工全程执行一遍 SOP,录屏计时 → ROI 基线测量协议(未来 POC 合同附件);
|
||||
5. 首客户工具链清点:每个工具标 CLI/API/GUI-only → GUI 风险预案;
|
||||
6. 新名字 + 商标检索(9/42 类 + 美国);
|
||||
7. 向森弗/锐测询价补齐 SANBlaze/ULINK 在华价格锚点。
|
||||
|
||||
产出:Go/No-go 备忘录 + 方案 V1.1。
|
||||
Kill:领域合伙人与替代路径双双落空;无人愿给 SOP;或"值守/复现"痛点排不进前三。
|
||||
|
||||
### Phase 1 — 单工位 POC(第 2-5 月,3.5 个月时间盒,硬件预算 2-5 万)
|
||||
|
||||
Phase 1 拆为 1a/1b 两段,依赖不同:
|
||||
|
||||
**1a 技术样机(可提前与 Phase 0 并行;2-4 周时间盒;零售/二手盘;无任何外部依赖)**
|
||||
- 验证四层差异化能力:心跳判定、分级恢复、断点续跑、证据采集——这四层与盘上跑什么固件无关(蓝屏就是蓝屏);
|
||||
- 刷写机制用 nvme-cli 标准通道排练:对同一固件镜像反复 download/commit/激活即可跑通全部机制,A/B 对比引擎用"任意两个变体"(两块盘/两套配置)先行验证;
|
||||
- DUT 来源:零售或闲鱼二手盘(¥200-800/块,刷坏不心疼);选型前实测目标型号的 fw-commit 行为与降级策略;国产 SMI/联芸主控盘的开卡工具生态可作内部台架的补充手段(灰色来源,仅限内部开发,不得用于对外演示);
|
||||
- 产出:一台"会自救的演示台"——同时是合伙人与客户谈判的最强道具;
|
||||
- 纪律:时间盒必须硬。造东西是舒适区,访谈是不适区;1a 结束时若合伙谈判与访谈无进展,该停就停。
|
||||
|
||||
**1b 真实 SOP 落地(被前置条件 0 门控:需要真实样品、固件 A/B 与厂商刷写工具)**
|
||||
- 团队:创始人 2 + 兼职验证工程师;
|
||||
- 硬件:x86 测试主机 + 可牺牲 DUT 若干 + **商品 KVM(JetKVM/PiKVM)+ 智能 PDU** + 温度传感 + UPS(树莓派方案降级为兼容选项);
|
||||
- 范围(相对 V1.0 MVP 再收缩):OS 按首客户 SOP 二选一;Web 控制台降级为 CLI + 静态 HTML 报告;AI 只做日志摘要 + 报告草稿。全部工程量押在**状态机 / 分级恢复 / 证据采集**三件事上;
|
||||
- 硬验收(在 V1.0 §4.5 基础上加硬):
|
||||
- 连续 7×24 无人值守运行不崩;
|
||||
- 100 循环 A/B 完整跑通 ≥2 次;
|
||||
- 三类注入故障(杀 Agent、蓝屏/panic、拔网线)自动恢复成功率 ≥80%;
|
||||
- 误盘 = 0;失败发生 → 证据包可下载 <5 分钟。
|
||||
- Kill:8 周内无法稳定完成 20 循环;或恢复成功率 <60%。
|
||||
|
||||
### Phase 2 — 首个付费试点(第 5-9 月,团队 4-5 人,累计现金 150-250 万)
|
||||
|
||||
- 商业:1-2 家付费 POC。两种打法:A. 付费 POC ¥8-20 万/单(含连接器开发)验收转订阅;B. 免费 POC + 验收付费 ¥30-50 万转订阅(标杆客户);
|
||||
- 产品化:安装部署包、角色权限、审计、正式控制台、连接器 SDK v0、缺陷系统对接(Jira/禅道);
|
||||
- 客户画像(按调研修正):珠三角/长三角的中型主控厂(得一微/忆芯/华存/兆芯/特纳飞档)、企业级新锐(大普微/得瑞/泽石/芯盛档)、头部模组厂固件测试部(江波龙/佰维/德明利档);10-30 工位、已有 SANBlaze/Quarch/温箱资产者优先;
|
||||
- 渠道:与森弗/锐测谈联合方案或转售分成(它们有全部目标客户关系,主业卖硬件,与编排层互补;风险:设备商自家软件向上长时渠道会站队);
|
||||
- 现场指标:客户现场 UCR ≥90%;"每周释放工时"报告由客户签字确认(销售武器);
|
||||
- 门槛:首笔外部收入 + 第二套 SOP 连接器复用率 ≥50%。
|
||||
|
||||
### Phase 3 — 产品化与多工位(第 10-18 月,累计现金 400-600 万)
|
||||
|
||||
- 3-5 家客户、20+ 工位、2-3 份年订阅合同(目标 ARR ¥100-200 万);
|
||||
- 多工位调度、资产/许可证管理、跨工位失败聚类(= V1.0 阶段 C 裁剪);发布通道/LTS 政策定版;
|
||||
- 硬件工程样机外包设计(若客户确有需求);失败指纹库跨客户沉淀(脱敏规则同步、客户授权制);
|
||||
- GTM:CFMS|MemoryS 2027(3 月·深圳)正式亮相;FMS 2027 看海外工业存储线索;
|
||||
- 决策点:产业资本融资扩张 vs 利润自养。
|
||||
|
||||
### Phase 4 — 决策门触发的扩张(18 月+)
|
||||
|
||||
兼容性循环测试柜(方向 4)、RMA 初检、来料质检、运营分析——维持 V1.0 §10.6 决策门设计,不排日历。新增一条远期选项:**向邻近垂直扩**(服务器 BMC/固件测试、GPU 老化、存储系统),这是突破利基天花板的主路径。
|
||||
|
||||
### 对外叙事
|
||||
|
||||
- 不讲"AI 测试 SSD",讲**"存储实验室的无人值守执行层 / 控制平面"**;
|
||||
- 对投资人:硬件测试数据平面已出独角兽(Nominal $1B)、AI 根因调查已被验证(Resolve.ai $1.5B)——**控制平面无人占位**,我们从存储垂直切入;同时诚实给出利基市场数字与邻近垂直扩张路径;
|
||||
- 对客户:固件回归不再需要工程师通宵值守(V1.0 原话保留,很好);ROI 一页纸 = 续年费 ≈ 0.5-1 名工程师年成本。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据缺口与两周补齐计划
|
||||
|
||||
| 缺口 | 补齐方法 |
|
||||
|---|---|
|
||||
| SANBlaze/ULINK/OakGate 在华成交价 | 以采购名义向森弗(上海)/锐测(深圳)询价;或开千里马/采招网付费账号查历史中标 |
|
||||
| "值守/夜班"一手抱怨文本 | 人工登录 BOSS 直聘/脉脉检索"SSD 测试 值班/夜班/两班倒";访谈中直接问 |
|
||||
| 美国商标明细 | USPTO TESS 人工检索新名字 |
|
||||
| 客户支付意愿实证 | Phase 0 的 10 个访谈中放入定价卡片测试(¥6k/¥9k/¥12k 每工位三档) |
|
||||
| PyNVMe3 实际部署形态 | 若访谈对象中有其客户,追问其值守/断点/取证痛点(验证互补空间) |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:主要信息来源(节选)
|
||||
|
||||
竞品官网与文档:ulinktech.com;teledynelecroy.com/ssdtesting;sanblaze.com(rest-interface / python-docs);quarch.com;pynv.me(pricing / EULA);github.com/pynvme/pynvme;serialtek.com;viavisolutions.com;labgrid.readthedocs.io;docs.lavasoftware.org;opentap.io;openhtf.com;tofupilot.com/pricing;nominal.io;siftstack.com;jetkvm.com;docs.pikvm.org;gl-inet.com;docs.temporal.io。
|
||||
行业与市场:StorageNewsletter(OakGate/SANBlaze Gen6、GYTech、SSD 制造商清单);futurememorystorage.com(FMS 2026);chinaflashmarket.com(CFM/MemoryS);IDC 中国企业级 SSD 数据(转引);东方财富/雪球/新浪财经(江波龙/佰维/德明利/大普微业绩)。
|
||||
中国本土:投中网/上证报(鸾起科技);爱集微(置富科技);realsys.com.cn(锐伺/Neosem);saniffer.com;reetest.com;0101semi.com(价格锚点);猎聘/BOSS 直聘 JD(联芸/江波龙/大普微/忆恒创源);国家专利(江波龙 CN121922187A、佰维 CN223167249U)。
|
||||
命名:labos.co;阿里云商标库(LABOS 第 9 类)。
|
||||
|
||||
*调研执行说明:部分搜索引擎与招标平台存在反爬/付费墙限制,凡未能取得一手数据处均在正文标注"未查到"并给出替代估算方法;重要结论均有至少两个独立来源交叉验证。竞品"缺口"结论基于公开资料,不排除存在未公开功能,建议关键项通过评估机/demo 实测复核。*
|
||||
|
||||
---
|
||||
|
||||
*本报告由外部顾问基于方案 V1.0 与 2026-07-27 联网调研撰写,供两位创始人决策参考。建议以本报告 §1.2 缺口清单和 §4 路线图为基础修订出方案 V1.1。*
|
||||
@@ -0,0 +1,26 @@
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.egg-info/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.turbo/
|
||||
|
||||
# 本地运行产物(开发态的 SQLite 库、对象存储、日志)
|
||||
var/
|
||||
*.db
|
||||
*.db-journal
|
||||
*.log
|
||||
|
||||
# 环境
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,74 @@
|
||||
.PHONY: help setup setup-py setup-web db-reset seed demo dev dev-api dev-web dev-agent dev-agent-loop test test-py e2e clean
|
||||
|
||||
SHELL := /bin/bash
|
||||
ROOT := $(CURDIR)
|
||||
PY := "$(ROOT)/.venv/bin/python"
|
||||
PIP := "$(ROOT)/.venv/bin/pip"
|
||||
CP := $(ROOT)/services/control-plane
|
||||
AGENT := $(ROOT)/services/host-agent
|
||||
CONSOLE := $(ROOT)/../前端UI八页面完成
|
||||
export PYTHONPATH := $(CP):$(AGENT)
|
||||
|
||||
help:
|
||||
@echo "FlashOps —— 常用命令"
|
||||
@echo ""
|
||||
@echo " make setup 建 venv、装依赖、建库、灌种子数据"
|
||||
@echo " make demo 无硬件跑通一整条固件 A/B 回归(含故障注入与恢复)"
|
||||
@echo " make dev 起控制平面与同源控制台 :8000"
|
||||
@echo " make dev-agent 向已启动的控制平面注册并发送一次真实 Agent 心跳"
|
||||
@echo " make test 后端单测 + 端到端 smoke"
|
||||
@echo " make db-reset 删库重建并重灌种子"
|
||||
@echo " make clean 清理本地运行产物"
|
||||
|
||||
setup: setup-py db-reset
|
||||
@echo ""
|
||||
@echo "✅ 就绪。下一步:make demo(看它跑)或 make dev(起服务)"
|
||||
|
||||
setup-py:
|
||||
@test -d .venv || python3 -m venv .venv
|
||||
@$(PIP) install --quiet --upgrade pip
|
||||
@$(PIP) install --quiet -r services/control-plane/requirements.txt
|
||||
@echo "✅ 后端依赖就绪"
|
||||
|
||||
db-reset:
|
||||
@rm -f "$(ROOT)/var/flashops.db"
|
||||
@rm -rf "$(ROOT)/var/objects"
|
||||
@mkdir -p "$(ROOT)/var/objects"
|
||||
@$(PY) -m flashops_control.cli init-db
|
||||
@$(PY) -m flashops_control.cli seed
|
||||
@echo "✅ 数据库已重建并灌入种子数据"
|
||||
|
||||
seed:
|
||||
@$(PY) -m flashops_control.cli seed
|
||||
|
||||
demo:
|
||||
@$(PY) -m flashops_control.cli demo
|
||||
|
||||
dev:
|
||||
@echo "控制台 http://localhost:8000/console/Dashboard.dc.html · API http://localhost:8000/docs"
|
||||
@$(PY) -m uvicorn flashops_control.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
dev-api:
|
||||
@$(PY) -m uvicorn flashops_control.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
dev-web:
|
||||
@cd "$(CONSOLE)" && "$(PY)" -m http.server 3000
|
||||
|
||||
dev-agent:
|
||||
@$(PY) -m flashops_agent --server http://127.0.0.1:8000 --once
|
||||
|
||||
dev-agent-loop:
|
||||
@$(PY) -m flashops_agent --server http://127.0.0.1:8000
|
||||
|
||||
test: test-py
|
||||
|
||||
test-py:
|
||||
@"$(ROOT)/.venv/bin/pytest" services/control-plane/tests services/host-agent/tests -q
|
||||
|
||||
e2e:
|
||||
@"$(ROOT)/.venv/bin/pytest" services/control-plane/tests/test_e2e_vertical_slice.py -q -s
|
||||
|
||||
clean:
|
||||
@rm -rf "$(ROOT)/var" "$(ROOT)/.pytest_cache"
|
||||
@find "$(ROOT)" -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||
@echo "✅ 已清理"
|
||||
@@ -0,0 +1,103 @@
|
||||
# FlashOps — 存储实验室无人值守执行层
|
||||
|
||||
> 固件回归测试的**控制平面**:把一条真实 SOP 变成可执行、可恢复、可取证的状态机,
|
||||
> 让工程师不必通宵值守。
|
||||
|
||||
**命名是临时的。** `FlashOps` 只是后端代号,控制台目前仍显示 `STORAGE LABOS`;
|
||||
商标与最终品牌确认后,需要统一后端常量、控制台标题和文档。
|
||||
|
||||
---
|
||||
|
||||
## 当前可运行基线
|
||||
|
||||
现在已有一条**无需硬件即可端到端跑通**的真实纵向链路:
|
||||
|
||||
```
|
||||
建任务 → 六项安全预检 → 资源加锁 → N 次模拟循环
|
||||
→ 注入 Agent 心跳丢失 → L1/L2 失败 → L3 带外 Reset 恢复
|
||||
→ 从循环检查点续跑 → Evidence Bundle 打包 → 前端实时可见
|
||||
```
|
||||
|
||||
全程使用内置模拟 DUT、测试主机与带外控制器,一条命令即可完成。当前实现重点是
|
||||
事件溯源写路径、安全门禁、状态转移、恢复记录和可复现演示;独立 Host Agent
|
||||
已经完成注册、token 与心跳最小链路,但步骤租约、真实硬件适配器、失败聚类执行逻辑
|
||||
与完整权限体系仍是后续工作,不在此基线中伪装完成。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
前置:Python 3.9+。**不需要 Node 或 Docker。**
|
||||
|
||||
```bash
|
||||
make setup # 建 venv、装依赖、建库、灌种子数据
|
||||
make demo # 跑一条 8 轮模拟回归(含故障注入、恢复与证据包)
|
||||
make dev # 起控制平面与控制台 :8000
|
||||
make dev-agent # 注册独立 Host Agent 并发送一次心跳
|
||||
make test # 控制平面 + Agent + API 端到端测试
|
||||
```
|
||||
|
||||
`make demo` 结束会打印 Run ID 与 Evidence Bundle 路径;`make dev` 后打开
|
||||
<http://localhost:8000/console/Tasks.dc.html>,运行预检并创建任务,再进入“实时运行”
|
||||
即可看到同一条 Run 的事件时间线。API 文档在 <http://localhost:8000/docs>。
|
||||
|
||||
---
|
||||
|
||||
## 仓库结构
|
||||
|
||||
```
|
||||
flashops/
|
||||
├─ docs/ 架构与规范(先读这里)
|
||||
│ ├─ architecture.md 五层架构 → 代码目录的逐层映射
|
||||
│ ├─ domain-model.md 7 个核心领域对象 → 数据库表
|
||||
│ ├─ state-machine.md Run/Step 状态机、恢复阶梯、检查点语义
|
||||
│ ├─ adapter-sdk.md 工具适配器契约(产品扩张的关键边界)
|
||||
│ ├─ workflow-spec.md SOP → YAML 工作流的 schema
|
||||
│ └─ decisions/ ADR:为什么这么选,以及什么时候该推翻
|
||||
├─ services/
|
||||
│ ├─ control-plane/ FastAPI 控制平面(事件、安全、模拟执行、证据)
|
||||
│ └─ host-agent/ 独立拉模式 Agent(注册、凭据、心跳)
|
||||
├─ ../前端UI八页面完成/ 控制台原型;由 FastAPI 挂载到 /console
|
||||
├─ docker-compose.yml 目标生产拓扑草案(尚未作为发布物验收)
|
||||
└─ Makefile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三条必须守住的架构红线
|
||||
|
||||
写后续代码时,这三条一旦破了,返工成本最高:
|
||||
|
||||
1. **控制面与数据面分离**——任务状态的唯一真相在控制平面数据库,测试主机本地只有缓存。
|
||||
Agent 挂了、主机重装了,Run 状态不丢。
|
||||
|
||||
2. **事件溯源**——所有状态变化以 append-only 事件写入 `run_events`,
|
||||
当前状态是事件的投影而非独立真相。时间线、审计、复现全部由此派生。
|
||||
*禁止*绕过 `events/recorder.py` 直接 UPDATE Run 状态。
|
||||
|
||||
3. **安全默认拒绝**——当前序列号、破坏性授权、系统盘、固件型号、Host 与 OOB
|
||||
任一不满足即在 `safety/` 层拒绝。真实 Agent 接入前还必须补签名命令模板,
|
||||
保证 Agent 只执行控制平面批准的动作。
|
||||
|
||||
---
|
||||
|
||||
## 当前状态与下一步
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 14 张持久化表 | ✅ 已落地 | SQLite 建表与种子数据可重复执行 |
|
||||
| Run 事件溯源 | ✅ 已落地 | 状态只能经 `events/recorder.py` 投影 |
|
||||
| Run 状态转移 | ✅ 已落地 | 纯函数转移表,终态不可恢复 |
|
||||
| 六项安全门禁 | ✅ 已落地 | 序列号、破坏性授权、系统盘、固件、Host、OOB |
|
||||
| L1–L5 恢复决策 | ✅ 已落地 | 纯函数;演示链路实际执行 L1–L3 |
|
||||
| 循环检查点续跑 | ✅ 模拟落地 | 循环边界保存,恢复后续跑 |
|
||||
| Evidence Bundle | ✅ 最小实现 | `manifest.json` + `events.ndjson`,完整率 100% |
|
||||
| 控制台 | 🟨 部分接入 | 任务中心与实时运行接 API;其余页面仍为静态样例 |
|
||||
| 独立 Host Agent | 🟨 部分落地 | 注册、token 摘要、心跳、主机探针与 15s/30s 状态老化已实现;Step 租约待实现 |
|
||||
| 真实 nvme-cli 与 OOB | ⬜ 待实现 | 需要可牺牲 DUT 与 JetKVM/PDU |
|
||||
| 失败签名执行与聚类 | ⬜ 待实现 | 数据表已定义,服务逻辑未接入 |
|
||||
| 多工位调度、认证与权限 | ⬜ 待实现 | 进入真实实验室前必须补齐 |
|
||||
|
||||
下一步建议实现 Step 租约、ACK、续租、完成上报与幂等事件,再把内置模拟执行器迁到
|
||||
独立 Agent;完成这层后再接真实 `nvme-cli` 与 JetKVM,才能保证硬件接入不绕过
|
||||
控制面安全规则。
|
||||
@@ -0,0 +1,35 @@
|
||||
# FlashOps production deployment
|
||||
|
||||
- Domain: `flashops.imagebrewing.com`
|
||||
- App root: `/data/wangzhan/app/storage-labos`
|
||||
- Backend bind: `127.0.0.1:18080`
|
||||
- Process manager: `systemd` unit `flashops.service`
|
||||
- Reverse proxy: Baota Nginx vhost `flashops.imagebrewing.com.conf`
|
||||
- Persistent data: `/data/wangzhan/app/storage-labos/flashops/var`
|
||||
- Private environment file: `/etc/flashops/flashops.env`
|
||||
|
||||
The service deliberately uses one worker because the current production store is
|
||||
SQLite. The current preview console is anonymously readable and writable through
|
||||
Nginx; do not connect real hardware or production data until application RBAC and
|
||||
approval gates are implemented. The ACME challenge path remains available so
|
||||
certificate renewal can complete.
|
||||
|
||||
Before enabling Agent enrollment, create the environment file without putting
|
||||
the secret in the repository or unit:
|
||||
|
||||
```bash
|
||||
sudo install -d -o root -g root -m 0755 /etc/flashops
|
||||
sudo install -o root -g root -m 0600 /dev/null /etc/flashops/flashops.env
|
||||
sudoedit /etc/flashops/flashops.env
|
||||
```
|
||||
|
||||
Add `FLASHOPS_AGENT_ENROLLMENT_TOKEN=<random secret>`, then restart the service.
|
||||
|
||||
Useful checks:
|
||||
|
||||
```bash
|
||||
sudo systemctl status flashops
|
||||
curl http://127.0.0.1:18080/api/v1/health
|
||||
sudo nginx -t
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
/usr/bin/systemctl reload nginx
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=FlashOps control plane
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
Group=ubuntu
|
||||
WorkingDirectory=/data/wangzhan/app/storage-labos/flashops
|
||||
EnvironmentFile=-/etc/flashops/flashops.env
|
||||
Environment=PYTHONPATH=/data/wangzhan/app/storage-labos/flashops/services/control-plane:/data/wangzhan/app/storage-labos/flashops/services/host-agent
|
||||
Environment=FLASHOPS_ENV=production
|
||||
Environment=FLASHOPS_API_HOST=127.0.0.1
|
||||
Environment=FLASHOPS_API_PORT=18080
|
||||
Environment=FLASHOPS_DATABASE_URL=sqlite+aiosqlite:////data/wangzhan/app/storage-labos/flashops/var/flashops.db
|
||||
Environment=FLASHOPS_OBJECT_STORE_URL=/data/wangzhan/app/storage-labos/flashops/var/objects
|
||||
Environment=FLASHOPS_CORS_ORIGINS=https://flashops.imagebrewing.com
|
||||
ExecStart=/data/wangzhan/app/storage-labos/flashops/.venv/bin/uvicorn flashops_control.main:app --host 127.0.0.1 --port 18080 --workers 1 --proxy-headers --forwarded-allow-ips=127.0.0.1
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
TimeoutStopSec=15
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/data/wangzhan/app/storage-labos/flashops/var
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,41 @@
|
||||
# Gitea deployment
|
||||
|
||||
The team Git service runs as Gitea 1.27.0 on the existing CVM:
|
||||
|
||||
- public URL: `https://git.imagebrewing.com`;
|
||||
- container HTTP bind: `127.0.0.1:13000`;
|
||||
- persistent data: `/data/gitea/data`;
|
||||
- database: Gitea-managed SQLite;
|
||||
- Git transport: HTTPS only; no extra public SSH port;
|
||||
- anonymous users may browse public repositories;
|
||||
- account registration requires administrator approval before sign-in.
|
||||
- daily backup target: `/chucun/wangzhan-production/backups/gitea/`.
|
||||
|
||||
The service is intentionally separate from the FlashOps runtime and database.
|
||||
Back up `/data/gitea/data` before an image upgrade. Pin the image version and
|
||||
review the Gitea release notes before changing it.
|
||||
|
||||
The current server does not have the Docker Compose plugin. The checked-in
|
||||
`docker-compose.yml` is the declarative service record for future rebuilds;
|
||||
the running container was created with the equivalent pinned `docker run`
|
||||
settings. Routine checks and restarts therefore use Docker directly:
|
||||
|
||||
```bash
|
||||
sudo docker ps --filter name=^gitea$
|
||||
sudo docker restart gitea
|
||||
sudo docker logs --tail 100 gitea
|
||||
curl -fsS http://127.0.0.1:13000/api/healthz
|
||||
```
|
||||
|
||||
Install Compose or recreate the container from the checked-in definition only
|
||||
during a planned maintenance window, after a verified backup.
|
||||
|
||||
Nginx terminates HTTPS and proxies to the loopback-only container port. Run
|
||||
`nginx -t` before every reload. Credentials and API tokens are never stored in
|
||||
this directory or committed to Git.
|
||||
|
||||
`gitea-backup.timer` runs daily at 03:20 Asia/Shanghai with a randomized delay.
|
||||
It uses Gitea's own dump command, writes the ZIP and SHA-256 sidecar to the
|
||||
mounted COS bucket, and removes the temporary local dump after a successful copy.
|
||||
The live repository and SQLite database remain on the local filesystem because
|
||||
COSFS is a backup destination, not a POSIX database filesystem.
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
backup_dir=/chucun/wangzhan-production/backups/gitea
|
||||
container_dump=/data/gitea-backup.zip
|
||||
host_dump=/data/gitea/data/gitea-backup.zip
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
target="$backup_dir/gitea-$stamp.zip"
|
||||
|
||||
install -d -o ubuntu -g ubuntu -m 0770 "$backup_dir"
|
||||
if [ -e "$host_dump" ]; then
|
||||
unlink "$host_dump"
|
||||
fi
|
||||
|
||||
docker exec -u git gitea sh -c \
|
||||
'mkdir -p /tmp/gitea-dump && find /tmp/gitea-dump -mindepth 1 -delete'
|
||||
docker exec -u git gitea gitea dump \
|
||||
--config /data/gitea/conf/app.ini \
|
||||
--file "$container_dump" \
|
||||
--tempdir /tmp/gitea-dump
|
||||
|
||||
cp "$host_dump" "$target"
|
||||
sha256sum "$target" > "$target.sha256"
|
||||
unlink "$host_dump"
|
||||
printf 'Gitea backup created: %s\n' "$target"
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.27.0
|
||||
container_name: gitea
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
GITEA__database__DB_TYPE: sqlite3
|
||||
GITEA__database__PATH: /data/gitea/gitea.db
|
||||
GITEA__server__DOMAIN: git.imagebrewing.com
|
||||
GITEA__server__ROOT_URL: https://git.imagebrewing.com/
|
||||
GITEA__server__HTTP_PORT: "3000"
|
||||
GITEA__server__DISABLE_SSH: "true"
|
||||
GITEA__server__OFFLINE_MODE: "true"
|
||||
GITEA__service__DISABLE_REGISTRATION: "false"
|
||||
GITEA__service__REGISTER_MANUAL_CONFIRM: "true"
|
||||
GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
|
||||
GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION: "false"
|
||||
GITEA__repository__DEFAULT_PRIVATE: public
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__cron__ENABLED: "true"
|
||||
GITEA__log__MODE: console
|
||||
GITEA__log__LEVEL: Info
|
||||
ports:
|
||||
- "127.0.0.1:13000:3000"
|
||||
volumes:
|
||||
- /data/gitea/data:/data
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Back up Gitea to mounted COS bucket
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/flashops-gitea-backup
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Daily Gitea backup timer
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:20:00 Asia/Shanghai
|
||||
RandomizedDelaySec=10m
|
||||
Persistent=true
|
||||
Unit=flashops-gitea-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name git.imagebrewing.com;
|
||||
|
||||
access_log /www/wwwlogs/git.imagebrewing.com.log;
|
||||
error_log /www/wwwlogs/git.imagebrewing.com.error.log;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:13000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name git.imagebrewing.com;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name git.imagebrewing.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/git.imagebrewing.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.imagebrewing.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:GiteaSSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
access_log /www/wwwlogs/git.imagebrewing.com.log;
|
||||
error_log /www/wwwlogs/git.imagebrewing.com.error.log;
|
||||
|
||||
client_max_body_size 100m;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:13000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name flashops.imagebrewing.com;
|
||||
|
||||
access_log /www/wwwlogs/flashops.imagebrewing.com.log;
|
||||
error_log /www/wwwlogs/flashops.imagebrewing.com.error.log;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
auth_basic off;
|
||||
root /var/www/html;
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name flashops.imagebrewing.com;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
auth_basic off;
|
||||
root /var/www/html;
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name flashops.imagebrewing.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/flashops.imagebrewing.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/flashops.imagebrewing.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:FlashOpsSSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
access_log /www/wwwlogs/flashops.imagebrewing.com.log;
|
||||
error_log /www/wwwlogs/flashops.imagebrewing.com.error.log;
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# 生产形态(客户内网私有化单机部署)。
|
||||
# 开发不需要它:make dev 用 SQLite + 进程内总线 + 本地目录,零外部依赖。
|
||||
#
|
||||
# 注意:Host Agent 不在这里。它跑在客户的测试主机上(Windows 服务 / systemd),
|
||||
# 不进容器——它要碰真实设备、真实驱动、真实串口。
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: flashops
|
||||
POSTGRES_USER: flashops
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?必须设置}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U flashops"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_USER:-flashops}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:?必须设置}
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
ports:
|
||||
- "9001:9001" # 控制台,仅内网暴露
|
||||
restart: unless-stopped
|
||||
|
||||
control-plane:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/control-plane/Dockerfile
|
||||
environment:
|
||||
FLASHOPS_DATABASE_URL: postgresql+asyncpg://flashops:${POSTGRES_PASSWORD}@postgres:5432/flashops
|
||||
FLASHOPS_BUS_URL: redis://redis:6379/0
|
||||
FLASHOPS_OBJECT_STORE_URL: s3://minio:9000/flashops
|
||||
FLASHOPS_OBJECT_STORE_KEY: ${MINIO_USER:-flashops}
|
||||
FLASHOPS_OBJECT_STORE_SECRET: ${MINIO_PASSWORD}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "8000:8000" # Agent 要能连上,所以对实验室网段开放
|
||||
restart: unless-stopped
|
||||
|
||||
console:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/console/Dockerfile
|
||||
environment:
|
||||
FLASHOPS_API_BASE: http://control-plane:8000
|
||||
depends_on:
|
||||
- control-plane
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
miniodata:
|
||||
@@ -0,0 +1,90 @@
|
||||
# Adapter SDK:产品扩张的关键边界
|
||||
|
||||
方案 §6.4 的判断是对的——**适配器是产品扩张的关键**。每接一个客户,
|
||||
新增的工作量应该收敛到"写一个适配器",而不是改引擎。这一波把这条边界钉死。
|
||||
|
||||
实现:`services/host-agent/flashops_agent/adapters/`
|
||||
|
||||
## 契约(7 个方法,一个都不能少)
|
||||
|
||||
```python
|
||||
class ToolAdapter(Protocol):
|
||||
name: str
|
||||
version: str
|
||||
|
||||
def discover(self, ctx: AdapterContext) -> Capabilities: ...
|
||||
def precheck(self, ctx: AdapterContext) -> CheckResult: ...
|
||||
def execute(self, step: StepSpec, ctx: AdapterContext) -> ExecutionHandle: ...
|
||||
def collect(self, handle: ExecutionHandle) -> EvidenceArtifact: ...
|
||||
def cancel(self, handle: ExecutionHandle) -> None: ...
|
||||
def health(self) -> HealthStatus: ...
|
||||
def normalize_result(self, raw: RawResult) -> UnifiedResult: ...
|
||||
```
|
||||
|
||||
| 方法 | 职责 | 什么时候被调 |
|
||||
|---|---|---|
|
||||
| `discover` | 报告本工具在这台主机上能干什么(版本、支持的设备、能力位) | Agent 启动 / 资产刷新 |
|
||||
| `precheck` | 执行前自检:工具在不在、权限够不够、设备在不在、参数合不合法 | 每个步骤执行前,**失败即拒绝执行** |
|
||||
| `execute` | 启动执行,**立即返回句柄,不阻塞** | 步骤开始 |
|
||||
| `collect` | 从句柄收集产物:stdout/stderr、结果文件、设备日志 | 步骤结束 / 中途取证 |
|
||||
| `cancel` | 终止执行(含进程树),保证不留孤儿进程 | 紧急停止 / 超时 / 恢复前 |
|
||||
| `health` | 适配器自身健康(工具还在吗、许可证过期没) | 心跳周期 |
|
||||
| `normalize_result` | 把工具原生输出翻译成统一结果模型 | `collect` 之后 |
|
||||
|
||||
## `normalize_result` 是最重要的一个
|
||||
|
||||
它决定了失败签名能不能跨工具聚类。原生输出千奇百怪,统一模型只有一种:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class UnifiedResult:
|
||||
outcome: Literal["PASS", "FAIL", "ERROR", "TIMEOUT"]
|
||||
error_codes: List[str] # 归一化后的错误码,如 ["NVME_STATUS_0x2002"]
|
||||
log_templates: List[str] # 日志模板化后的指纹,数字/路径已替换为占位符
|
||||
metrics: Dict[str, float] # iops / latency_p99 / temperature_c ...
|
||||
device_state: DeviceState # 枚举状态、固件版本、SMART 关键项
|
||||
integrity: IntegrityState # OK / MISMATCH / NOT_CHECKED
|
||||
artifacts: List[str] # 产物 URI
|
||||
```
|
||||
|
||||
**适配器的实现者只需要保证一件事**:同样的故障,`error_codes` 与
|
||||
`log_templates` 要稳定。不稳定 → 签名散开 → 聚类失效 → 同一个问题重复提单,
|
||||
客户第一时间就会发现这个系统在制造噪音。
|
||||
|
||||
日志模板化规则在 `flashops_agent/adapters/templating.py`:
|
||||
数字 → `<N>`,十六进制 → `<HEX>`,路径 → `<PATH>`,UUID → `<UUID>`,
|
||||
时间戳 → `<TS>`。所以
|
||||
`"nvme0n1: I/O error, sector 12345678 at 2026-07-27T03:14:15"`
|
||||
归一为 `"nvme<N>n<N>: I/O error, sector <N> at <TS>"`。
|
||||
|
||||
## 这一波带的适配器
|
||||
|
||||
| 适配器 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `ShellAdapter` | ✅ 真实 | 执行签名命令模板,进程树管理、超时、输出捕获 |
|
||||
| `NvmeCliAdapter` | ⚠️ 真实骨架 + 模拟回退 | 命令与解析是真的;无真实设备时走模拟器 |
|
||||
| `FioAdapter` | ⚠️ 真实骨架 + 模拟回退 | 解析 fio `--output-format=json` |
|
||||
| `SimulatedDutAdapter` | ✅ 模拟 | 可注入故障的假 DUT,让全链路无硬件可跑 |
|
||||
| `VendorFlashAdapter` | ⬜ 占位 | 客户私有刷写工具——**这个必须等拿到真实 SOP 再写** |
|
||||
|
||||
`VendorFlashAdapter` 故意留空并在导入时抛 `NotImplementedError`。
|
||||
猜客户的刷写工具长什么样是纯浪费——报告 Phase 0 的结论是先拿 SOP 再写代码。
|
||||
|
||||
## 写一个新适配器的清单
|
||||
|
||||
1. 继承 `BaseAdapter`,实现 7 个方法
|
||||
2. 在 `adapters/__init__.py` 的 `REGISTRY` 里注册(key 就是工作流 YAML 里的 `adapter:`)
|
||||
3. 危险命令**必须**在控制平面 `safety/templates.py` 注册签名模板;
|
||||
适配器里不允许拼接任意命令行——参数只能来自模板 schema 校验过的字典
|
||||
4. 写一个 `tests/adapters/test_<name>.py`:至少覆盖
|
||||
`precheck` 失败路径、超时路径、`normalize_result` 的两个不同故障输出
|
||||
5. 跑 `make test` 里的适配器契约测试(`test_adapter_contract.py` 会对
|
||||
`REGISTRY` 里每个适配器自动断言 7 个方法齐全且签名正确)
|
||||
|
||||
## 边界纪律
|
||||
|
||||
- 适配器**不知道** Run、Workflow、状态机的存在。它只认 `StepSpec` 和 `AdapterContext`。
|
||||
- 适配器**不做**重试和恢复决策。挂了就如实报告,重试与恢复是控制平面的事。
|
||||
- 适配器**不写**数据库,产物写本地临时目录,由 Agent 上传。
|
||||
- 适配器里**不允许**出现 `if customer == "X"` 这种分支。客户差异靠不同适配器 +
|
||||
工作流参数表达,不靠代码里的 if。
|
||||
@@ -0,0 +1,90 @@
|
||||
# 控制平面 ↔ Host Agent 协议
|
||||
|
||||
> **当前状态(2026-07-27)**:独立 Host Agent 已实现注册、注册口令、bearer token、
|
||||
> 本地 `0600` 凭据文件、主机指纹和心跳;控制平面只保存 token SHA-256 摘要。
|
||||
> 心跳迟到 15 秒会把 Host 降级为 `DEGRADED`,丢失 30 秒会标记为 `OFFLINE`;
|
||||
> 安全预检会先刷新该状态,避免失联 Host 被误判为可用。
|
||||
> Step 租约、事件、完成与产物端点仍是下一阶段契约。`make demo` 暂时继续使用
|
||||
> 控制平面内置模拟执行器验证状态、恢复与证据链。
|
||||
|
||||
Agent 是**拉模式**。控制平面永远不主动连接测试主机。
|
||||
|
||||
理由:客户测试主机在内网/NAT 后面,很多实验室根本不给外部反连;
|
||||
拉模式还顺带解决了 Agent 重启后的重新接入问题。
|
||||
|
||||
## 端点
|
||||
|
||||
| 状态 | 方法 | 路径 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 已实现 | POST | `/api/v1/agent/register` | 首次注册 / token 轮换;上报主机指纹与工具能力 |
|
||||
| 已实现 | POST | `/api/v1/agent/{agent_id}/heartbeat` | bearer 心跳,上报健康和设备探针;返回待办指令 |
|
||||
| 已实现 | GET | `/api/v1/agent/hosts` | 查看 Host 与 Agent 在线投影,不返回 token |
|
||||
| 待实现 | POST | `/api/v1/agent/{agent_id}/lease` | 领取下一个步骤(长轮询,最多挂 20s) |
|
||||
| 待实现 | POST | `/api/v1/agent/{agent_id}/steps/{step_id}/events` | 批量上报执行事件与日志片段 |
|
||||
| 待实现 | POST | `/api/v1/agent/{agent_id}/steps/{step_id}/complete` | 上报步骤终态 + UnifiedResult + 产物清单 |
|
||||
| 待实现 | POST | `/api/v1/agent/{agent_id}/artifacts` | 上传产物(日志 / 结果文件 / 画面) |
|
||||
|
||||
带外控制器走独立端点(它必须能在主机死后独立上报):
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/oob/{controller_id}/heartbeat` | 带外心跳 + 电源状态 + 温度 + 画面指纹 |
|
||||
| POST | `/api/v1/oob/{controller_id}/observation` | 主动上报观测(蓝屏画面、掉电、按钮触发) |
|
||||
|
||||
## 心跳返回的指令
|
||||
|
||||
心跳响应已经带 `commands` 字段;当前固定为空数组。命令队列接入后,Agent 收到后立即执行:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"server_time": "2026-07-27T03:14:15Z",
|
||||
"commands": [
|
||||
{"kind": "CANCEL_STEP", "step_id": "..."},
|
||||
{"kind": "SOFT_RESTART", "reason": "recovery_L1"},
|
||||
{"kind": "EMERGENCY_STOP", "run_id": "..."},
|
||||
{"kind": "REFRESH_ASSETS"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
紧急停止走心跳而不是新连接——心跳是唯一能保证到达的通道。
|
||||
最坏情况延迟 = 心跳间隔(5s)。真正需要毫秒级的停止靠带外物理急停,不靠软件。
|
||||
|
||||
## 租约(lease)语义
|
||||
|
||||
```
|
||||
Agent ──lease──▶ 控制平面
|
||||
├─ 有活:分配步骤,step → DISPATCHED,写租约(TTL 60s)
|
||||
└─ 没活:长轮询挂起最多 20s,返回 204
|
||||
Agent ──ack───▶ step → RUNNING
|
||||
(租约到期未 ack 或未 complete)→ step 回 PENDING,重新派发
|
||||
```
|
||||
|
||||
租约 TTL 必须**大于**步骤超时?不——恰恰相反:租约由 Agent 在执行期间通过心跳续期。
|
||||
Agent 死了 → 心跳停 → 租约到期 → 步骤回收。这是 Agent 崩溃能被发现的机制之一
|
||||
(另一条是心跳超时判定,见 state-machine.md §5)。
|
||||
|
||||
## 认证
|
||||
|
||||
首次注册带 `X-FlashOps-Enrollment-Token`。开发态未配置注册口令时允许本机演示;
|
||||
生产态未配置时注册接口返回 503。注册成功下发 bearer token,之后每个请求带
|
||||
`Authorization: Bearer <token>`。`agent_id` 与 `test_host_id` 绑定,重新注册会轮换 token。
|
||||
|
||||
原始 token 仅存于 Agent 本机 `0600` state 文件;控制平面数据库只保存 SHA-256 摘要。
|
||||
Agent 客户端保留可选 gateway Basic Auth 能力,供未来重新启用边界认证或部署在其他网关后使用;
|
||||
当前 `flashops.imagebrewing.com` 预览环境未启用这层认证。
|
||||
|
||||
**下一波必须升级**:方案 §6.2 要求"每个 Agent 使用唯一证书或密钥"。
|
||||
bearer token 在客户内网够用,但要进客户安全评审得上 mTLS。
|
||||
升级点在 `api/deps.py::require_agent()` 一处。
|
||||
|
||||
## 幂等(待实现)
|
||||
|
||||
Step 租约接入时,所有 Agent → 控制平面的 Step 写请求必须带
|
||||
`Idempotency-Key`(Agent 生成的 UUID)。控制平面需要记录并拒绝重复副作用;
|
||||
`run_events` 已有唯一约束 `(run_id, idempotency_key)`,但当前注册 / 心跳链路尚未实现
|
||||
通用的请求幂等存储。
|
||||
|
||||
这一条在真实实验室里不是可选项:主机重启、网线松动、Wi-Fi 掉线是常态,
|
||||
没有幂等就会出现"一次故障记了三条"的时间线,取证时说不清。
|
||||
@@ -0,0 +1,99 @@
|
||||
# 架构:五层 → 代码目录的逐层映射
|
||||
|
||||
> **实现状态(2026-07-27)**:本文描述目标架构。当前已落地控制平面的模型、
|
||||
> 安全门禁、事件记录、Run 转移表、恢复决策、最小证据包、内置模拟执行器,以及
|
||||
> 独立 Host Agent 的注册、token、心跳和 Host 状态老化链路;Step 租约、Adapter
|
||||
> 执行、消息总线替换和生产对象存储仍未落地。
|
||||
|
||||
方案 §5.1 定义了五层架构。这里给出每一层**落到哪个目录、边界在哪、谁不能调用谁**。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 交互与集成层 apps/console (Next.js) · CLI · Open API │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 控制平面 services/control-plane/flashops_control/ │
|
||||
│ services/ 编排 safety/ 门禁 api/ 接口 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 执行平面 services/host-agent/flashops_agent/ │
|
||||
│ adapters/ 工具适配器 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 带外平面 services/host-agent/flashops_agent/oob/ │
|
||||
│ (独立进程/独立网络,模拟实现在 simulator/) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 证据与智能层 control-plane/.../evidence/ · signatures/ │
|
||||
│ events/ 事件总线 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 依赖方向(单向,不可逆)
|
||||
|
||||
```
|
||||
console ──HTTP/SSE──▶ control-plane ◀──HTTP──── host-agent
|
||||
│ │
|
||||
│ ├─▶ adapters ─▶ 客户工具
|
||||
▼ │
|
||||
数据库 └─▶ oob ─▶ 带外控制器
|
||||
```
|
||||
|
||||
**硬规则:**
|
||||
|
||||
- `host-agent` **不得**导入 `control-plane` 的任何模块。两者只通过 `docs/agent-protocol.md`
|
||||
定义的 HTTP 契约通信。理由:Agent 要能单文件分发到客户 Windows 机器,
|
||||
控制平面代码不进客户测试主机。
|
||||
- `control-plane` **不得**主动连接 Agent(Agent 是拉模式)。理由:客户测试主机在
|
||||
内网/NAT 后面,反向连接在真实实验室里活不下来。
|
||||
- `engine/` **不得**直接读写数据库。它只操作 `Snapshot` 值对象,返回 `Effect` 列表。
|
||||
持久化由 `runtime.py` 施加。理由:状态机要能不起数据库单测。
|
||||
- `api/` **不得**包含业务判断。路由只做校验 + 调用 `engine`/`services`。
|
||||
- 任何危险动作(刷写、Format、Sanitize、断电)**必须**先过 `safety/`,
|
||||
且 `safety/` 的拒绝发生在下发给 Agent 之前。
|
||||
|
||||
## 控制平面内部分层
|
||||
|
||||
```
|
||||
flashops_control/
|
||||
├─ config.py 配置(产品名、DSN、路径、超时策略)
|
||||
├─ db.py 引擎/会话/建表
|
||||
├─ models/ SQLAlchemy 表定义(贫血模型,只管持久化)
|
||||
├─ schemas.py Pydantic 出入参(当前最小 API 契约)
|
||||
├─ events/
|
||||
│ ├─ bus.py 进程内事件总线(生产换 NATS/Redis,接口不变)
|
||||
│ └─ recorder.py 事件溯源写入:唯一允许改 Run 状态的入口
|
||||
├─ engine/
|
||||
│ ├─ states.py Run/Step 状态枚举 + 转移表(纯数据)
|
||||
│ ├─ recovery.py 五级恢复阶梯策略(纯函数)
|
||||
│ └─ (目标)planner/runtime:独立 Agent 接入后补齐
|
||||
├─ safety/
|
||||
│ ├─ gates.py DUT 绑定 / 系统盘保护 / 破坏性白名单
|
||||
│ └─ templates.py 签名命令模板注册表 + 参数 schema 校验
|
||||
├─ signatures/ (目标)失败签名(8 要素哈希)与聚类
|
||||
├─ evidence/ 最小 Evidence Bundle(manifest + 事件流)
|
||||
├─ services/runs.py 当前内置模拟编排(后续拆成 Agent + runtime)
|
||||
├─ seed.py 可重复执行的模拟资产与工作流
|
||||
└─ api/ FastAPI 路由
|
||||
```
|
||||
|
||||
**`engine/` 的纯度是刻意的。** 当前 `states.py` / `recovery.py`
|
||||
不导入 SQLAlchemy、不导入 FastAPI、不做 I/O、不看时钟
|
||||
(时间从 `Snapshot.now` 传入)。这让状态机的全部分支都能用普通 pytest 覆盖,
|
||||
不需要起库、不需要 sleep。后面几波会不断往状态机里加分支(新故障类型、
|
||||
新恢复策略、多工位调度),这条边界是唯一能防止它烂掉的东西。
|
||||
|
||||
## 开发形态 vs 生产形态
|
||||
|
||||
| 组件 | 开发(`make dev`) | 生产(`docker-compose`) | 换的时候动哪 |
|
||||
|---|---|---|---|
|
||||
| 数据库 | SQLite (aiosqlite) | PostgreSQL 16 | 只改 `DATABASE_URL` |
|
||||
| 队列/总线 | 进程内 asyncio | Redis / NATS | `events/bus.py` 换实现 |
|
||||
| 对象存储 | `var/objects/` 本地目录 | MinIO | `storage.py` 换实现 |
|
||||
| Agent | 内置模拟执行器 + 独立 Agent 心跳 | 客户主机上的 systemd/Windows 服务 | 补齐 Step lease/runtime |
|
||||
| 带外 | 模拟控制器 | 树莓派 / JetKVM / 智能 PDU | `oob/` 换实现 |
|
||||
|
||||
三处"换实现"都在接口后面,且开发态实现本身就是测试替身——不存在"生产才发现跑不通"。
|
||||
|
||||
## 为什么不用 Temporal / Airflow
|
||||
|
||||
见 [ADR-0003](decisions/0003-own-state-machine-over-temporal.md)。一句话:
|
||||
恢复语义(带外断电、冻结现场、误盘防护)是本产品的差异化本身,
|
||||
不能交给通用工作流引擎的重试模型。等到多工位跨节点调度成为瓶颈时再评估 Temporal,
|
||||
`runtime.py` 是唯一需要替换的文件。
|
||||
@@ -0,0 +1,39 @@
|
||||
# ADR-0001:Monorepo 与技术栈选型
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 状态:已接受
|
||||
|
||||
## 背景
|
||||
|
||||
第一波要同时出前端控制台、控制平面、Host Agent 三个可交付物,团队规模是 1-2 人。
|
||||
|
||||
## 决定
|
||||
|
||||
单仓库(monorepo),三个可独立部署的单元:`apps/console`、`services/control-plane`、
|
||||
`services/host-agent`。技术栈按方案 §6.1:Next.js + FastAPI + PostgreSQL。
|
||||
|
||||
**开发态零外部依赖**:SQLite 替 Postgres、进程内 asyncio 替 Redis、
|
||||
本地目录替 MinIO。三者都在接口后面,`docker-compose.yml` 给出生产形态。
|
||||
|
||||
## 理由
|
||||
|
||||
- 1-2 人团队用多仓库,跨仓改一个接口要开三个 PR,纯损耗。
|
||||
- 控制平面与 Agent 的 HTTP 契约会在接第一个客户时频繁改。同仓库能一次改完、
|
||||
一次跑通端到端测试。
|
||||
- 开发态零依赖是为了**降低启动摩擦**:这台机器上没有 Docker,客户现场的
|
||||
Windows 测试主机上也大概率装不了 Docker。能 `python -m` 直接跑起来的东西,
|
||||
在实验室里活得久。
|
||||
- Python 3.9 兼容:客户测试主机的 Python 版本不可控,Agent 必须往低了兼容。
|
||||
所以全仓库用 `Optional[X]` 而不是 `X | None`,用 `List[X]` 而不是 `list[X]`。
|
||||
|
||||
## 代价
|
||||
|
||||
- SQLite 与 Postgres 有行为差异(并发写、JSON 查询、事务隔离)。
|
||||
缓解:ORM 层不用任何方言特有类型;CI 后续要在 Postgres 上再跑一遍测试。
|
||||
- monorepo 在团队超过 5 人后会需要更强的 CI 分区。届时再拆。
|
||||
|
||||
## 什么时候推翻
|
||||
|
||||
- 团队 >5 人且前后端分离开发节奏明显不同步 → 拆仓。
|
||||
- Agent 需要单文件分发到无 Python 环境的 Windows → 把 Agent 用 Go 重写
|
||||
(方案 §6.1 已经把这条写成演进建议)。届时 HTTP 契约不变,只换实现语言。
|
||||
@@ -0,0 +1,44 @@
|
||||
# ADR-0002:以事件溯源作为 Run 的唯一真相
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 状态:已接受
|
||||
|
||||
## 背景
|
||||
|
||||
产品要交付的三件事——**统一时间线**、**审计**、**自动复现**——都需要回答同一个问题:
|
||||
"当时到底按什么顺序发生了什么"。
|
||||
|
||||
同时,观测是**双通道**的:Agent 报一套,带外控制器报一套,两者时钟不同步,
|
||||
主机断电时 Agent 那一路会直接消失。
|
||||
|
||||
## 决定
|
||||
|
||||
`run_events` 是 append-only 的唯一真相。`runs` / `run_steps` / `recovery_actions`
|
||||
是可以从事件重放重建的**读模型**。
|
||||
|
||||
序号 `seq` 由控制平面单点分配(不是时间戳排序),Agent 事件与带外事件进同一条序列。
|
||||
|
||||
仓储层**不提供** `update_run_state()` 类 API。改状态的唯一入口是
|
||||
`events/recorder.py::record()`,它写事件并同步更新投影。
|
||||
|
||||
## 理由
|
||||
|
||||
- 主机断电时,Agent 的最后几条日志可能永远到不了。带外控制器的观测
|
||||
(掉电时刻、画面指纹)是那段时间唯一的证据。两路事件必须在同一条时间线上
|
||||
才能交叉校验——"Agent 最后一条日志 seq=812,带外报掉电 seq=813"这种判断
|
||||
只有单点序号能给。
|
||||
- 审计要求记录"操作者、审批者、命令模板版本、输入参数、结果、撤销动作"
|
||||
(方案 §6.6)。这些天然是事件,硬塞进当前状态表会做成一堆冗余字段。
|
||||
- 自动复现(§8.4)需要"失败前后的最小步骤区间"。有完整事件流才能裁剪。
|
||||
|
||||
## 代价
|
||||
|
||||
- 写路径变长,每次状态变化多一次插入。
|
||||
- `run_events` 会很大:100 循环 × 每轮 ~30 事件 = 3000 条/Run。
|
||||
缓解:事件按 run_id 分区友好,日志正文不进事件表(只存对象存储 URI)。
|
||||
- 开发者容易忍不住直接 UPDATE。缓解:仓储层不给这个 API,code review 卡这一条。
|
||||
|
||||
## 什么时候推翻
|
||||
|
||||
不推翻。真要优化,是加快照(每 N 个事件存一次状态快照加速重放),
|
||||
不是放弃事件溯源。
|
||||
@@ -0,0 +1,51 @@
|
||||
# ADR-0003:自研状态机,不用 Temporal / Airflow
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 状态:已接受(方案 §6.1 把 Temporal 列为演进建议,这里给出不在首版用的理由)
|
||||
|
||||
## 背景
|
||||
|
||||
工作流编排有成熟方案:Temporal、Airflow、Prefect、OpenTAP。
|
||||
自研状态机通常是坏味道。
|
||||
|
||||
## 决定
|
||||
|
||||
首版自研:`engine/states.py` 转移表 + `engine/state_machine.py` 纯函数
|
||||
+ `engine/runtime.py` 异步循环。
|
||||
|
||||
## 理由
|
||||
|
||||
产品的差异化恰好落在通用引擎**不管**的那一层:
|
||||
|
||||
1. **恢复语义不是重试。** Temporal 的重试模型是"再调一次这个 activity"。
|
||||
我们需要的是"主机蓝屏了 → 带外触发主板 Reset → 等主机起来 → 验证 DUT
|
||||
重新枚举 → 从检查点续跑"。这是跨越进程、跨越主机生死的物理恢复,
|
||||
不是函数重试。硬套通用引擎会变成在 activity 里塞一堆状态判断,比自研更难维护。
|
||||
|
||||
2. **非幂等步骤默认不重跑。** 通用引擎的默认值是"重试是安全的"。
|
||||
我们这里最贵的一条规则是"刷写步骤挂了**不要**自动重跑"。
|
||||
跟框架默认值对着干,不如自己控制。
|
||||
|
||||
3. **冻结现场是一等状态。** 数据完整性失败要立刻停止一切覆盖性动作并保留现场。
|
||||
通用引擎里这是"失败",我们这里它是一个需要保持很久、等人来看的活状态。
|
||||
|
||||
4. **可测试性。** 纯函数状态机能把"30 秒心跳超时后带外在线则走 L3"这种规则
|
||||
在毫秒内测完。挂在 Temporal 上要起测试环境。后面几波会疯狂往状态机加分支,
|
||||
测试成本是决定性因素。
|
||||
|
||||
5. 依赖成本:Temporal 需要自己的数据库和服务集群。客户是**内网私有化部署**,
|
||||
多一个必须运维的组件就多一个交付摩擦。
|
||||
|
||||
## 代价
|
||||
|
||||
- 跨节点调度、持久化定时器、版本化工作流这些 Temporal 白送的能力要自己做。
|
||||
这一波不需要(单工位),Phase 3 多工位时会痛。
|
||||
- 自研状态机容易随时间腐化成 if 堆。缓解:转移表是纯数据、`decide()` 是纯函数、
|
||||
每加一个分支必须配单测,三条规矩写进了 `docs/state-machine.md §6`。
|
||||
|
||||
## 什么时候推翻
|
||||
|
||||
多工位跨节点调度成为瓶颈时(Phase 3,预计 20+ 工位)。
|
||||
届时替换范围仅限 `runtime.py`——`states.py` / `state_machine.py` / `recovery.py`
|
||||
是纯逻辑,可以原样搬进 Temporal 的 workflow 定义里。
|
||||
这也是把它们做成纯函数的第二个理由。
|
||||
@@ -0,0 +1,51 @@
|
||||
# ADR-0004:安全门禁放在控制平面,Agent 保持"笨"
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 状态:已接受
|
||||
|
||||
## 背景
|
||||
|
||||
危险动作有三类:刷固件、Format/Sanitize、断电。任何一次误盘 = PoC 判定失败
|
||||
(方案 §9.3:0 次错盘、0 次系统盘破坏)。
|
||||
|
||||
门禁可以放在 Agent(离设备近,判断准)或控制平面(离决策近,可审计)。
|
||||
|
||||
## 决定
|
||||
|
||||
**全部放控制平面**,且拒绝发生在**下发之前**。Agent 只执行已批准的命令模板 +
|
||||
schema 校验过的参数,自己不做任何"这条命令安不安全"的判断。
|
||||
|
||||
Agent 侧保留的唯一防线是 `precheck`(设备在不在、路径对不对),
|
||||
它是**执行前自检**,不是安全决策。
|
||||
|
||||
## 理由
|
||||
|
||||
- **可审计**:审批记录、模板版本、操作者、参数必须与决策在同一个地方,
|
||||
否则审计链断在 Agent 上。客户安全评审第一个问的就是这个。
|
||||
- **Agent 可信度低**:Agent 跑在客户的测试主机上,那台机器会蓝屏、会被人手动改配置、
|
||||
会装乱七八糟的工具。安全判断不能依赖一个随时可能处于异常状态的环境。
|
||||
- **升级路径**:安全规则改了,改控制平面就全网生效。要靠 Agent 判断,
|
||||
就得推送 Agent 升级到每台客户主机——在实验室里这是以周计的事。
|
||||
- **可测试**:门禁是纯函数(`safety/gates.py`),误盘场景可以在单测里穷举,
|
||||
不需要真的接一块盘来试。
|
||||
|
||||
## 具体门禁(`safety/gates.py`)
|
||||
|
||||
1. **DUT 绑定**:序列号 + 设备路径 + `allow_destructive` 标签三信号一致才放行。
|
||||
任一不匹配 → 拒绝,记 `SAFETY_REJECTED` 事件。
|
||||
2. **系统盘保护**:解析目标设备的挂载点/启动标志/分区表,命中系统盘立即拒绝。
|
||||
这一条**没有** override 开关,代码里不给绕过的口子。
|
||||
3. **命令模板白名单**:危险命令只能来自 `safety/templates.py` 注册表,
|
||||
参数受 JSON Schema 约束。不接受自由文本命令行。
|
||||
4. **环境指纹一致性**:A/B 对比的两次运行环境指纹不一致 → 结论标记为不可比。
|
||||
|
||||
## 代价
|
||||
|
||||
- 控制平面必须掌握足够的设备信息才能判断(依赖 Agent 上报的设备探针数据)。
|
||||
上报延迟 → 判断基于稍旧的数据。缓解:危险步骤执行前强制刷新一次探针,
|
||||
Agent 的 `precheck` 再做一次现场核对,两者不一致直接拒绝。
|
||||
- 多一次往返。危险动作本来就不该快。
|
||||
|
||||
## 什么时候推翻
|
||||
|
||||
不推翻。可以增强(加人工审批门、加双人复核),但不下放到 Agent。
|
||||
@@ -0,0 +1,43 @@
|
||||
# ADR-0005:模拟器是一等公民,不是测试脚手架
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 状态:已接受
|
||||
|
||||
## 背景
|
||||
|
||||
第一波没有真实硬件(没有可牺牲的 DUT、没有带外控制器、没有温箱)。
|
||||
常规做法是先写代码、等硬件到位再联调。
|
||||
|
||||
## 决定
|
||||
|
||||
把**模拟 DUT + 模拟带外控制器 + 故障注入器**做成产品代码的一部分
|
||||
(`services/host-agent/flashops_agent/simulator/`),与真实适配器共用同一套接口,
|
||||
而不是塞进 `tests/` 当替身。
|
||||
|
||||
模拟器支持注入的故障至少覆盖报告 Phase 1a 要求验证的三类:
|
||||
杀 Agent、蓝屏/panic、拔网线;外加掉盘与数据完整性失败。
|
||||
|
||||
## 理由
|
||||
|
||||
1. **它是长期资产,不是临时替身。** 有了真硬件之后,模拟器仍然是唯一能
|
||||
在 CI 里跑"100 循环 + 注入 20 次故障"的东西。真设备跑一轮要几小时,
|
||||
CI 里不可能天天跑。
|
||||
2. **恢复逻辑的测试覆盖只能靠它。** 五级恢复阶梯里的 L4/L5(ATX 长按、AC 断电)
|
||||
在真机上每测一次都有风险且很慢。这条路径恰恰是产品价值所在,必须能高频回归。
|
||||
3. **它是谈判道具的底座。** 报告把 Phase 1a 的产出定义为"一台会自救的演示台——
|
||||
同时是合伙人与客户谈判的最强道具"。在拿到硬件之前,模拟器版本已经能把
|
||||
完整故事演一遍:注入蓝屏 → 看着它自己救回来 → 断点续跑 → 出证据包。
|
||||
4. **它划清了接口。** 能被模拟器替换掉的地方,就是硬件接入的边界。
|
||||
写模拟器的过程本身就在逼迫接口设计正确。
|
||||
|
||||
## 代价
|
||||
|
||||
- 模拟器与真实实现可能漂移(模拟器里能过、真机上过不了)。
|
||||
缓解:`tests/test_adapter_contract.py` 对模拟与真实适配器跑**同一套契约测试**;
|
||||
真实适配器接入后,契约测试是第一道闸。
|
||||
- 有"在模拟器上跑通了就以为完事了"的风险。缓解:README 的能力表里,
|
||||
模拟实现一律标 ⚠️ 或"模拟",不标 ✅ 真实。
|
||||
|
||||
## 什么时候推翻
|
||||
|
||||
不推翻。真实硬件接入后模拟器保留,作为 CI 的默认执行后端。
|
||||
@@ -0,0 +1,96 @@
|
||||
# 领域模型:7 个核心对象 → 14 张表
|
||||
|
||||
方案 §5.3 列了 7 个核心领域对象。当前 SQLAlchemy 元数据包含 14 张表。
|
||||
额外表用于事件溯源、恢复动作、资源锁、失败实例与审计;它们不是新的顶层产品概念,
|
||||
而是核心对象的行为记录和读模型。
|
||||
|
||||
## 对象 → 表
|
||||
|
||||
| 方案对象 | 表 | 补充说明 |
|
||||
|---|---|---|
|
||||
| DUT | `duts` | 序列号是防错盘的第一信号 |
|
||||
| Test Host | `test_hosts` + `oob_controllers` | 带外控制器独立成表:它必须能在主机死后独活 |
|
||||
| Firmware Artifact | `firmware_artifacts` | 哈希 + 签名 + 适用型号,缺一不可审计 |
|
||||
| Workflow | `workflows` | 存 spec 原文 + spec_hash,改一个字就是新版本 |
|
||||
| Run | `runs` + `run_steps` + `run_events` + `recovery_actions` | 见下 |
|
||||
| Failure Signature | `failure_signatures` + `run_failures` | 签名是聚类锚点,失败实例挂在它下面 |
|
||||
| Evidence Bundle | `evidence_bundles` | manifest 记录完整率,缺字段要能查出来 |
|
||||
| —(新增) | `resource_locks` | DUT/主机独占,防并发踩踏 |
|
||||
| —(新增) | `audit_log` | 谁在什么时候用哪个模板做了什么 |
|
||||
|
||||
## Run 为什么拆成四张表
|
||||
|
||||
`runs` 只存**当前投影**(状态、进度、结论)。真相在 `run_events`。
|
||||
|
||||
```
|
||||
run_events (append-only, 唯一真相)
|
||||
│ projection
|
||||
▼
|
||||
runs / run_steps / recovery_actions (可重建的读模型)
|
||||
```
|
||||
|
||||
任何时刻都能靠重放 `run_events` 重建 `runs` 的状态——这是"事件溯源"落地的含义,
|
||||
也是统一时间线、审计、自动复现三个功能的共同地基。
|
||||
|
||||
**代价**:写路径必须走 `events/recorder.py`。仓储层故意**没有**
|
||||
`update_run_state()` 这种 API。想改状态?记一条事件,投影自己会跟上。
|
||||
|
||||
## 关键字段的设计理由
|
||||
|
||||
### `duts.allow_destructive` + `duts.serial`
|
||||
|
||||
破坏性动作(Format / Sanitize / 刷写)的三重校验:序列号匹配、
|
||||
`allow_destructive=True`、设备路径不是系统盘。三个信号缺一个就拒绝执行。
|
||||
误盘一次 = PoC 判定失败(方案 §9.3),所以这个字段不给 API 直接改,
|
||||
只能走带审批记录的资产管理接口。
|
||||
|
||||
### `runs.env_fingerprint`(JSON,不可变快照)
|
||||
|
||||
任务开始时冻结:主板 / BIOS / OS / 内核 / 驱动 / Agent 版本 / 工具版本 / DUT 固件。
|
||||
A/B 对比的前提是环境相同——固件之外任何一项变了,对比结论就不成立。
|
||||
签名哈希也吃这个指纹,所以"换了台机器复现不出来"能被自动识别为不同签名。
|
||||
|
||||
### `runs.unattended_completion` + `runs.human_touches`
|
||||
|
||||
这两个字段是**销售武器**,不是技术指标。UCR(无人值守完成率)和人工触碰次数
|
||||
是客户签字确认 ROI 的凭据(方案 §9.3、报告 Phase 2 现场指标)。
|
||||
从第一行代码就记,不要等到要卖了才补。
|
||||
|
||||
### `run_events.seq`(每个 run 内单调递增)
|
||||
|
||||
时间戳会因为主机断电、时钟漂移、带外控制器与主机时钟不同步而乱序。
|
||||
`seq` 由控制平面单点分配,保证时间线可重放。带外事件与 Agent 事件
|
||||
进同一条序列——双通道观测的交叉校验靠它。
|
||||
|
||||
### `failure_signatures.hash`(8 要素)
|
||||
|
||||
```
|
||||
hash(workflow_step, normalized_error_codes, log_templates, host_state,
|
||||
dut_enumeration_state, data_integrity_state, recovery_outcome,
|
||||
environment_fingerprint)
|
||||
```
|
||||
|
||||
只用错误码会把"掉盘"和"脚本超时"归成一类,聚类就废了。
|
||||
`log_templates` 是日志模板化后的结果(数字/路径/时间被替换为占位符),
|
||||
不是原始日志——否则每条日志都是新签名。
|
||||
|
||||
### `run_failures.failure_class`
|
||||
|
||||
`DUT_DEFECT` / `INFRA_FAILURE` / `SCRIPT_FAILURE` / `DATA_INTEGRITY` / `UNKNOWN`。
|
||||
|
||||
**`INFRA_FAILURE` 必须与 `DUT_DEFECT` 分开统计**(方案 §4.3)。
|
||||
把网络断了、磁盘满了算成 SSD 缺陷,客户第一周就不信任这个系统了。
|
||||
基础设施误报率 <5% 是 MVP 硬指标。
|
||||
|
||||
## 状态字段一览
|
||||
|
||||
| 表 | 字段 | 取值 |
|
||||
|---|---|---|
|
||||
| `runs` | `state` | QUEUED / PREFLIGHT / RUNNING / RECOVERING / PAUSED / FROZEN / COMPLETED / ABORTED / REJECTED |
|
||||
| `runs` | `verdict` | PASS / FAIL / INCONCLUSIVE / null |
|
||||
| `run_steps` | `state` | PENDING / DISPATCHED / RUNNING / SUCCEEDED / FAILED / TIMED_OUT / SKIPPED / CANCELLED |
|
||||
| `test_hosts` | `status` | ONLINE / DEGRADED / OFFLINE / UNKNOWN |
|
||||
| `duts` | `status` | IDLE / IN_USE / QUARANTINED / MISSING |
|
||||
| `recovery_actions` | `outcome` | RECOVERED / FAILED / ESCALATED / FROZEN |
|
||||
|
||||
详见 [state-machine.md](state-machine.md)。
|
||||
@@ -0,0 +1,134 @@
|
||||
# 状态机、恢复阶梯与检查点语义
|
||||
|
||||
这是整个产品的核心。差异化不在"能跑命令",在**跑挂了之后会发生什么**。
|
||||
|
||||
当前实现:`engine/states.py`(Run 转移表)、`engine/recovery.py`(恢复纯函数)、
|
||||
`events/recorder.py`(事件与投影写入口)以及 `services/runs.py`(内置模拟编排)。
|
||||
Step 租约状态机与独立 runtime 属于下一阶段。
|
||||
|
||||
---
|
||||
|
||||
## 1. Run 状态机
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ QUEUED │ 等资源(DUT/主机独占锁)
|
||||
└────┬─────┘
|
||||
│ 锁到手
|
||||
┌────▼─────┐
|
||||
│ PREFLIGHT│ 安全门禁:序列号绑定、系统盘保护、
|
||||
└──┬────┬──┘ 破坏性白名单、环境指纹冻结
|
||||
门禁拒绝 │ │ 全过
|
||||
┌────▼┐ │
|
||||
│REJEC│ │
|
||||
│ TED │ │
|
||||
└─────┘ │
|
||||
┌───────▼──────┐
|
||||
┌─────────▶│ RUNNING │◀────────┐
|
||||
│ └──┬───┬───┬───┘ │ 恢复成功,
|
||||
│ 人工继续 │ │ │ │ 从检查点续跑
|
||||
┌────┴───┐ │ │ │ ┌────┴──────┐
|
||||
│ PAUSED │◀───────┘ │ └───────▶│ RECOVERING│
|
||||
└────────┘ 人工暂停 │ 心跳超时/ └────┬──────┘
|
||||
│ 蓝屏/掉盘 │ 阶梯耗尽
|
||||
全部循环完成 │ 或数据完整性失败
|
||||
┌─────▼─────┐ ┌────▼────┐
|
||||
│ COMPLETED │ │ FROZEN │ 冻结现场,
|
||||
│ PASS/FAIL │ └─────────┘ 停止一切覆盖性动作
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
任何非终态 → `ABORTED`(紧急停止:Web / 物理按钮 / 带外均可触发)。
|
||||
|
||||
**终态**:`COMPLETED` / `ABORTED` / `FROZEN` / `REJECTED`。终态不可再转移,
|
||||
`assert_transition()` 会抛 `IllegalTransition`——这个异常在生产里意味着有代码
|
||||
绕过了事件溯源,属于必须修的 bug,不是可以吞掉的告警。
|
||||
|
||||
转移表是 `states.py` 里的一份纯数据 `RUN_TRANSITIONS: Dict[RunState, FrozenSet[RunState]]`。
|
||||
加新状态时只改这张表 + 补一条单测,不改 `runtime.py`。
|
||||
|
||||
## 2. Step 状态机
|
||||
|
||||
```
|
||||
PENDING ──▶ DISPATCHED ──▶ RUNNING ──┬──▶ SUCCEEDED
|
||||
▲ ├──▶ FAILED ──┐
|
||||
│ ├──▶ TIMED_OUT┤
|
||||
└──── 重试(attempt+1)◀───────────┴─────────────┘
|
||||
└──▶ CANCELLED / SKIPPED
|
||||
```
|
||||
|
||||
- `DISPATCHED`:控制平面已把步骤租给某个 Agent,等待 Agent 确认接手。
|
||||
租约有超时——Agent 领了活就死了,租约到期步骤回到 `PENDING` 重新派发。
|
||||
- 重试次数由工作流步骤的 `retry` 声明,**默认 0**。
|
||||
危险步骤(刷写、Format)默认不重试:重试一次刷写可能把盘刷成砖。
|
||||
|
||||
## 3. 五级恢复阶梯
|
||||
|
||||
对应方案 §7.2。触发条件是**双通道观测**的判定结果,不是单一信号:
|
||||
|
||||
| 级别 | 动作 | 前提 | 典型触发 |
|
||||
|---|---|---|---|
|
||||
| L1 | `AGENT_SOFT` Agent 优雅停止/软重启进程 | Agent 心跳还在 | 测试脚本卡死、进程僵死 |
|
||||
| L2 | `OS_REBOOT` 通过 OS 远程通道重启 | 主机网络还通 | Agent 进程崩溃、驱动异常 |
|
||||
| L3 | `OOB_RESET` 带外触发主板 Reset | 带外控制器在线 | 心跳超时 + 带外仍在线(蓝屏典型) |
|
||||
| L4 | `OOB_ATX_POWER` 带外模拟 ATX 长按关机再开机 | 带外控制器在线 | Reset 无效(挂在 BIOS/固件态) |
|
||||
| L5 | `OOB_AC_CYCLE` 整机 AC 断电 → 安全间隔 → 上电 | 带外控制器在线 | ATX 无效;也是 DUT 掉盘的最后手段 |
|
||||
| — | `FREEZE` 冻结现场,停止自动恢复 | — | 超过限定次数;或**数据完整性失败立即触发** |
|
||||
|
||||
**关键规则(`recovery.py` 里是硬编码的,不给配置覆盖):**
|
||||
|
||||
1. **数据完整性失败直接跳到 FREEZE**,不走阶梯。
|
||||
哈希/读回比较不一致意味着现场有价值,任何重启都可能毁掉证据。
|
||||
2. **带外控制器离线时,L3-L5 不可用**,直接降级到 FREEZE 并标记
|
||||
`INFRA_FAILURE`——不能因为带外没接就把 DUT 判成坏盘。
|
||||
3. **每一级恢复都要留证**:恢复前抓画面/串口/温度,恢复后验证 DUT 重新枚举。
|
||||
`recovery_actions` 表记录每一级的 trigger / outcome / evidence_uri。
|
||||
4. **恢复成功 ≠ 步骤成功**。恢复只是把系统救回可执行状态,
|
||||
原步骤按检查点语义决定是续跑还是重跑。
|
||||
|
||||
## 4. 检查点与断点续跑
|
||||
|
||||
检查点在**步骤边界**创建,不在步骤中间——中间态无法保证幂等。
|
||||
|
||||
```
|
||||
loop_index=37, step=fio-workload, state=SUCCEEDED
|
||||
└─▶ checkpoint 写入:{loop_index: 37, next_step: verify-enumeration, dut_fw: "A"}
|
||||
```
|
||||
|
||||
恢复后的续跑规则:
|
||||
|
||||
| 挂在哪 | 恢复后 |
|
||||
|---|---|
|
||||
| 步骤已 `SUCCEEDED`,检查点已落 | 从下一步继续 |
|
||||
| 步骤 `RUNNING` 时挂了,步骤幂等(`idempotent: true`) | 重跑该步骤 |
|
||||
| 步骤 `RUNNING` 时挂了,步骤非幂等(默认,如刷写) | **不重跑**,标记 `INCONCLUSIVE`,进入人工确认 |
|
||||
| 步骤是循环体中的一环 | 回到该 `loop_index` 的起点重跑整轮 |
|
||||
|
||||
非幂等步骤不自动重跑,是这一层最保守也最重要的默认值。
|
||||
"自动重试把盘刷坏"是这个产品最容易砸招牌的失败模式。
|
||||
|
||||
## 5. 心跳与失联判定
|
||||
|
||||
```
|
||||
Agent 心跳间隔 5s
|
||||
├─ 15s 无心跳 → 主机 DEGRADED,记事件,不动作
|
||||
├─ 30s 无心跳 → 判定失联,查带外:
|
||||
│ ├─ 带外在线 + 主机有电 → 走恢复阶梯 L3
|
||||
│ ├─ 带外在线 + 主机无电 → INFRA_FAILURE(供电问题,不是 DUT)
|
||||
│ └─ 带外也离线 → INFRA_FAILURE + FREEZE(不能瞎判)
|
||||
└─ 心跳恢复 → 校验 Run 状态一致性,续跑
|
||||
```
|
||||
|
||||
阈值在 `config.py`,但**判定逻辑在 `state_machine.py` 里是纯函数**:
|
||||
输入 `(last_heartbeat_age, oob_online, oob_power_state, now)`,输出判定。
|
||||
所以"30 秒超时"这种行为可以不等 30 秒就测出来。
|
||||
|
||||
## 6. 怎么给状态机加东西(后续几波会反复做)
|
||||
|
||||
1. 在 `states.py` 加状态/事件枚举 + 改转移表
|
||||
2. 在 `state_machine.py::decide()` 加分支,返回新的 `Effect`
|
||||
3. 在 `runtime.py` 加该 `Effect` 的施加逻辑(唯一碰 I/O 的地方)
|
||||
4. 在 `tests/test_state_machine.py` 加纯函数单测——不起库、不 sleep
|
||||
|
||||
如果第 2 步发现需要在 `decide()` 里做 I/O,说明抽象漏了,
|
||||
应该把需要的数据加进 `Snapshot`,而不是在纯函数里开个口子。
|
||||
@@ -0,0 +1,123 @@
|
||||
# 工作流规范:SOP → 可执行状态机
|
||||
|
||||
> **当前状态**:这是待实现的发布与物化规范。数据库中已经保存一份符合该结构的
|
||||
> 演示工作流,但 `WorkflowSpec` 完整 schema 校验、YAML 发布器与 `planner.py`
|
||||
> 尚未落地;当前 API 不接受任意工作流上传。
|
||||
|
||||
工作流必须是**确定性的结构化配置**,不是让大模型自由生成命令(方案 §6.3、§8.1)。
|
||||
AI 可以把自然语言 SOP 草拟成下面这份 YAML,但**必须过 schema 校验 + 人工发布**才能执行。
|
||||
|
||||
目标实现位置:`engine/planner.py`(物化)、`schemas.py::WorkflowSpec`(校验)
|
||||
与 `workflows/fw-ab-regression.yaml`(版本化示例)。
|
||||
|
||||
## Schema
|
||||
|
||||
```yaml
|
||||
key: fw-ab-regression # 全局唯一,改 key = 新工作流
|
||||
name: 固件 A/B 回归
|
||||
version: 3 # 每次发布 +1;spec_hash 变了但 version 没变 → 拒绝发布
|
||||
danger_level: high # none | low | high —— high 需要审批记录
|
||||
source_sop: "客户X_固件回归SOP_v2.1(脱敏)"
|
||||
|
||||
params: # 运行时参数,带类型与默认值
|
||||
loops: {type: int, default: 100, min: 1, max: 10000}
|
||||
dut_serial: {type: string, required: true}
|
||||
host: {type: string, required: true}
|
||||
fw_a: {type: string, required: true} # firmware_artifact id
|
||||
fw_b: {type: string, required: true}
|
||||
|
||||
resources: # 独占锁,PREFLIGHT 阶段获取,终态释放
|
||||
- {type: dut, ref: params.dut_serial, exclusive: true}
|
||||
- {type: test_host, ref: params.host, exclusive: true}
|
||||
|
||||
policy:
|
||||
heartbeat_timeout_s: 30
|
||||
max_recovery_per_loop: 3 # 单轮循环内恢复超过 3 次 → FREEZE
|
||||
max_recovery_total: 20
|
||||
on_data_integrity_failure: freeze # 硬编码值,写在这里只是为了显式
|
||||
|
||||
steps:
|
||||
- key: preflight
|
||||
type: precheck
|
||||
adapter: safety
|
||||
|
||||
- key: flash-fw-a
|
||||
type: command
|
||||
adapter: nvme_cli
|
||||
template: nvme_fw_download_commit # 只能引用已注册的签名模板
|
||||
params: {firmware: "{{ params.fw_a }}", slot: 1, action: 3}
|
||||
danger: high
|
||||
timeout_s: 300
|
||||
idempotent: false # 默认值,写出来是为了提醒:挂了不自动重跑
|
||||
checkpoint: true
|
||||
|
||||
- key: regression-loop
|
||||
type: loop
|
||||
count: "{{ params.loops }}"
|
||||
body:
|
||||
- key: workload
|
||||
type: command
|
||||
adapter: fio
|
||||
template: fio_seq_rw
|
||||
timeout_s: 600
|
||||
retry: 1
|
||||
idempotent: true
|
||||
- key: enumeration-check
|
||||
type: device_check
|
||||
adapter: nvme_cli
|
||||
expect: {enumerated: true, link_speed_min: "8GT/s"}
|
||||
- key: integrity-check
|
||||
type: command
|
||||
adapter: shell
|
||||
template: sha256_readback
|
||||
on_fail: freeze # 覆盖默认处置
|
||||
checkpoint: true # 每轮结束落检查点
|
||||
|
||||
- key: report
|
||||
type: report
|
||||
adapter: builtin
|
||||
```
|
||||
|
||||
## 步骤类型(MVP 六种)
|
||||
|
||||
| type | 语义 | 备注 |
|
||||
|---|---|---|
|
||||
| `command` | 执行签名命令模板 | 唯一能碰危险动作的类型 |
|
||||
| `device_check` | 设备探针断言(枚举/固件版本/链路速率/SMART) | 不改变设备状态 |
|
||||
| `wait` | 等待固定时长或条件 | 用于温度稳定、上电间隔 |
|
||||
| `loop` | 固定次数循环,body 是步骤列表 | 支持嵌套一层 |
|
||||
| `precheck` | 安全门禁 | 每个工作流的第一步都应该是它 |
|
||||
| `report` | 生成报告与 Evidence Bundle | 内置适配器 |
|
||||
|
||||
企业版才加的:`http`、`parallel`、`subflow`、`approval`、`instrument`。
|
||||
这一波故意不做——`planner.py` 里遇到未知 type 直接拒绝发布,不静默跳过。
|
||||
|
||||
## 模板变量
|
||||
|
||||
只支持 `{{ params.X }}` 和 `{{ loop.index }}` 两种插值,**不支持表达式求值**。
|
||||
理由:能求值就能注入。需要计算的场景写进适配器,不写进 YAML。
|
||||
|
||||
## 版本与 Git
|
||||
|
||||
工作流 YAML 进 `workflows/` 目录、进 Git。发布时控制平面记录:
|
||||
|
||||
```
|
||||
workflows.spec 原文
|
||||
workflows.spec_hash sha256(规范化后的 spec)
|
||||
workflows.version 发布号
|
||||
```
|
||||
|
||||
`spec_hash` 变了而 `version` 没变 → 拒绝发布。理由:A/B 对比的结论必须能追溯到
|
||||
**具体哪一版流程**,否则"上周跑的和这周跑的是不是同一个流程"就说不清了。
|
||||
|
||||
## `on_fail` 的可选处置
|
||||
|
||||
| 值 | 行为 |
|
||||
|---|---|
|
||||
| `retry`(默认,受 `retry:` 次数约束) | 重试该步骤 |
|
||||
| `fail_run` | 整个 Run 判 FAIL 并结束 |
|
||||
| `continue` | 记失败,继续下一步(用于非关键采集步骤) |
|
||||
| `freeze` | 立刻冻结现场,停止一切覆盖性动作 |
|
||||
| `recover` | 进入恢复阶梯 |
|
||||
|
||||
数据完整性相关的步骤**只允许** `freeze`,planner 校验时强制。
|
||||
@@ -0,0 +1,5 @@
|
||||
"""FlashOps 控制平面。"""
|
||||
|
||||
from .config import APP_NAME
|
||||
|
||||
__all__ = ["APP_NAME"]
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI 路由。"""
|
||||
@@ -0,0 +1,276 @@
|
||||
"""拉模式 Host Agent 的注册、认证与心跳 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import session_scope
|
||||
from ..enums import HostStatus
|
||||
from ..models import TestHost, iso_z, utcnow
|
||||
from ..schemas import (
|
||||
AgentHeartbeatBody,
|
||||
AgentLeaseBody,
|
||||
AgentRegisterBody,
|
||||
AgentStepAttemptBody,
|
||||
AgentStepCompleteBody,
|
||||
AgentStepEventsBody,
|
||||
)
|
||||
from ..services.agent_status import refresh_agent_host
|
||||
from ..services.step_leases import (
|
||||
StepLeaseConflict,
|
||||
acknowledge_step,
|
||||
complete_step,
|
||||
record_step_events,
|
||||
renew_step,
|
||||
try_lease_next_step,
|
||||
)
|
||||
from .deps import agent_token_digest, require_agent
|
||||
|
||||
router = APIRouter(prefix="/api/v1/agent", tags=["host-agent"])
|
||||
|
||||
|
||||
def _idempotency_key(value: str) -> str:
|
||||
if len(value) > 60:
|
||||
raise HTTPException(status_code=422, detail="Idempotency-Key 过长")
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Idempotency-Key 必须是 UUID"
|
||||
) from exc
|
||||
return value
|
||||
|
||||
|
||||
def _lease_conflict(exc: StepLeaseConflict) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
|
||||
|
||||
def _check_enrollment_token(provided: Optional[str]) -> None:
|
||||
settings = get_settings()
|
||||
required = settings.agent_enrollment_token
|
||||
if settings.env == "production" and not required:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="生产环境尚未配置 Agent 注册口令",
|
||||
)
|
||||
if required and not hmac.compare_digest(provided or "", required):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Agent 注册口令无效",
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_host(body: AgentRegisterBody) -> Dict[str, object]:
|
||||
async with session_scope() as session:
|
||||
host: Optional[TestHost] = None
|
||||
if body.host_id:
|
||||
host = await session.get(TestHost, body.host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Test Host 不存在")
|
||||
elif body.host_name:
|
||||
host = await session.scalar(
|
||||
select(TestHost).where(TestHost.name == body.host_name)
|
||||
)
|
||||
else:
|
||||
host = await session.scalar(select(TestHost).order_by(TestHost.created_at))
|
||||
|
||||
if host is None:
|
||||
host = TestHost(name=body.host_name or "agent-host-" + uuid.uuid4().hex[:8])
|
||||
session.add(host)
|
||||
await session.flush()
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
host.agent_id = host.agent_id or "agent_" + uuid.uuid4().hex[:20]
|
||||
host.agent_token = agent_token_digest(raw_token)
|
||||
host.agent_version = body.agent_version
|
||||
host.os_family = body.os_family
|
||||
if body.os_version is not None:
|
||||
host.os_version = body.os_version
|
||||
if body.kernel is not None:
|
||||
host.kernel = body.kernel
|
||||
if body.cpu is not None:
|
||||
host.cpu = body.cpu
|
||||
if body.motherboard is not None:
|
||||
host.motherboard = body.motherboard
|
||||
if body.bios_version is not None:
|
||||
host.bios_version = body.bios_version
|
||||
if body.ip_address is not None:
|
||||
host.ip_address = body.ip_address
|
||||
if body.toolchain:
|
||||
host.toolchain = body.toolchain
|
||||
if body.labels:
|
||||
host.labels = {**(host.labels or {}), **body.labels}
|
||||
host.status = HostStatus.ONLINE.value
|
||||
host.last_heartbeat_at = utcnow()
|
||||
payload = {
|
||||
"agent_id": host.agent_id,
|
||||
"host_id": host.id,
|
||||
"token": raw_token,
|
||||
"heartbeat_interval_s": get_settings().timing.heartbeat_interval_s,
|
||||
"server_time": iso_z(host.last_heartbeat_at),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register_agent(
|
||||
body: AgentRegisterBody,
|
||||
enrollment_token: Optional[str] = Header(
|
||||
default=None, alias="X-FlashOps-Enrollment-Token"
|
||||
),
|
||||
) -> Dict[str, object]:
|
||||
_check_enrollment_token(enrollment_token)
|
||||
return await _resolve_host(body)
|
||||
|
||||
|
||||
@router.post("/{agent_id}/heartbeat")
|
||||
async def heartbeat(
|
||||
agent_id: str,
|
||||
body: AgentHeartbeatBody,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
) -> Dict[str, object]:
|
||||
async with session_scope() as session:
|
||||
host = await require_agent(session, agent_id, authorization)
|
||||
host.status = (
|
||||
HostStatus.ONLINE.value if body.healthy else HostStatus.DEGRADED.value
|
||||
)
|
||||
host.last_heartbeat_at = utcnow()
|
||||
if body.agent_version:
|
||||
host.agent_version = body.agent_version
|
||||
if body.ip_address:
|
||||
host.ip_address = body.ip_address
|
||||
if body.toolchain:
|
||||
host.toolchain = body.toolchain
|
||||
if body.device_probe:
|
||||
host.labels = {
|
||||
**(host.labels or {}),
|
||||
"agent_probe": body.device_probe,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"server_time": iso_z(host.last_heartbeat_at),
|
||||
"heartbeat_interval_s": get_settings().timing.heartbeat_interval_s,
|
||||
"commands": [],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{agent_id}/lease")
|
||||
async def lease_step(
|
||||
agent_id: str,
|
||||
body: AgentLeaseBody,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
) -> object:
|
||||
deadline = time.monotonic() + min(
|
||||
body.wait_timeout_s, get_settings().timing.lease_poll_timeout_s
|
||||
)
|
||||
while True:
|
||||
async with session_scope() as session:
|
||||
host = await require_agent(session, agent_id, authorization)
|
||||
lease = await try_lease_next_step(session, host)
|
||||
if lease is not None:
|
||||
return lease
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
await asyncio.sleep(min(get_settings().engine_tick_s, remaining))
|
||||
|
||||
|
||||
@router.post("/{agent_id}/steps/{step_id}/ack")
|
||||
async def ack_step(
|
||||
agent_id: str,
|
||||
step_id: str,
|
||||
body: AgentStepAttemptBody,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
idempotency_key: str = Header(alias="Idempotency-Key"),
|
||||
) -> Dict[str, object]:
|
||||
key = _idempotency_key(idempotency_key)
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
host = await require_agent(session, agent_id, authorization)
|
||||
return await acknowledge_step(session, host, step_id, body.attempt, key)
|
||||
except StepLeaseConflict as exc:
|
||||
raise _lease_conflict(exc) from exc
|
||||
|
||||
|
||||
@router.post("/{agent_id}/steps/{step_id}/renew")
|
||||
async def renew_step_lease(
|
||||
agent_id: str,
|
||||
step_id: str,
|
||||
body: AgentStepAttemptBody,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
idempotency_key: str = Header(alias="Idempotency-Key"),
|
||||
) -> Dict[str, object]:
|
||||
key = _idempotency_key(idempotency_key)
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
host = await require_agent(session, agent_id, authorization)
|
||||
return await renew_step(session, host, step_id, body.attempt, key)
|
||||
except StepLeaseConflict as exc:
|
||||
raise _lease_conflict(exc) from exc
|
||||
|
||||
|
||||
@router.post("/{agent_id}/steps/{step_id}/events")
|
||||
async def step_events(
|
||||
agent_id: str,
|
||||
step_id: str,
|
||||
body: AgentStepEventsBody,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
idempotency_key: str = Header(alias="Idempotency-Key"),
|
||||
) -> Dict[str, object]:
|
||||
key = _idempotency_key(idempotency_key)
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
host = await require_agent(session, agent_id, authorization)
|
||||
return await record_step_events(
|
||||
session, host, step_id, body.attempt, body.events, key
|
||||
)
|
||||
except StepLeaseConflict as exc:
|
||||
raise _lease_conflict(exc) from exc
|
||||
|
||||
|
||||
@router.post("/{agent_id}/steps/{step_id}/complete")
|
||||
async def finish_step(
|
||||
agent_id: str,
|
||||
step_id: str,
|
||||
body: AgentStepCompleteBody,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
idempotency_key: str = Header(alias="Idempotency-Key"),
|
||||
) -> Dict[str, object]:
|
||||
key = _idempotency_key(idempotency_key)
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
host = await require_agent(session, agent_id, authorization)
|
||||
return await complete_step(session, host, step_id, body, key)
|
||||
except StepLeaseConflict as exc:
|
||||
raise _lease_conflict(exc) from exc
|
||||
|
||||
|
||||
@router.get("/hosts")
|
||||
async def list_agent_hosts() -> List[Dict[str, object]]:
|
||||
async with session_scope() as session:
|
||||
hosts = (
|
||||
await session.scalars(select(TestHost).order_by(TestHost.name))
|
||||
).all()
|
||||
for host in hosts:
|
||||
refresh_agent_host(host)
|
||||
return [
|
||||
{
|
||||
"host_id": host.id,
|
||||
"host_name": host.name,
|
||||
"agent_id": host.agent_id,
|
||||
"agent_version": host.agent_version,
|
||||
"status": host.status,
|
||||
"last_heartbeat_at": iso_z(host.last_heartbeat_at),
|
||||
"ip_address": host.ip_address,
|
||||
"toolchain": host.toolchain,
|
||||
}
|
||||
for host in hosts
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""HTTP dependencies shared by Agent endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import TestHost
|
||||
|
||||
|
||||
def agent_token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def bearer_token(authorization: Optional[str]) -> str:
|
||||
scheme, separator, value = (authorization or "").partition(" ")
|
||||
if not separator or scheme.lower() != "bearer" or not value.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Agent bearer token 缺失",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return value.strip()
|
||||
|
||||
|
||||
async def require_agent(
|
||||
session: AsyncSession,
|
||||
agent_id: str,
|
||||
authorization: Optional[str],
|
||||
) -> TestHost:
|
||||
host = await session.scalar(select(TestHost).where(TestHost.agent_id == agent_id))
|
||||
token = bearer_token(authorization)
|
||||
if (
|
||||
host is None
|
||||
or not host.agent_token
|
||||
or not hmac.compare_digest(host.agent_token, agent_token_digest(token))
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Agent 身份无效",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return host
|
||||
@@ -0,0 +1,258 @@
|
||||
"""控制台所需的最小纵向 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..db import session_scope
|
||||
from ..enums import EventKind, EventSource, RunState, Severity
|
||||
from ..events import append_event
|
||||
from ..models import EvidenceBundle, Run, RunEvent, ResourceLock, utcnow
|
||||
from ..schemas import CreateRunBody, HumanActionBody, PreflightBody
|
||||
from ..services.runs import (
|
||||
cancel_simulation,
|
||||
create_run,
|
||||
event_to_dict,
|
||||
prepare_agent_run,
|
||||
preflight_for_body,
|
||||
run_to_dict,
|
||||
start_simulation,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> Dict[str, str]:
|
||||
return {"status": "ok", "service": "flashops-control-plane"}
|
||||
|
||||
|
||||
@router.post("/preflight")
|
||||
async def preflight(body: PreflightBody) -> Dict[str, object]:
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
return await preflight_for_body(session, body)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/runs", status_code=status.HTTP_201_CREATED)
|
||||
async def create_run_route(body: CreateRunBody) -> Dict[str, object]:
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
run = await create_run(session, body)
|
||||
if body.execution_mode == "agent_dry_run":
|
||||
await prepare_agent_run(session, run)
|
||||
payload = run_to_dict(run)
|
||||
if body.execution_mode == "simulated":
|
||||
start_simulation(run.id)
|
||||
return payload
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/runs/demo", status_code=status.HTTP_201_CREATED)
|
||||
async def create_demo_run() -> Dict[str, object]:
|
||||
return await create_run_route(CreateRunBody())
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def list_runs(limit: int = 30) -> List[Dict[str, object]]:
|
||||
async with session_scope() as session:
|
||||
runs = (
|
||||
await session.scalars(
|
||||
select(Run).order_by(Run.created_at.desc()).limit(min(max(limit, 1), 100))
|
||||
)
|
||||
).all()
|
||||
return [run_to_dict(run) for run in runs]
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}")
|
||||
async def get_run(run_id: str) -> Dict[str, object]:
|
||||
async with session_scope() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run 不存在")
|
||||
payload = run_to_dict(run)
|
||||
bundle = await session.scalar(
|
||||
select(EvidenceBundle)
|
||||
.where(EvidenceBundle.run_id == run_id)
|
||||
.order_by(EvidenceBundle.created_at.desc())
|
||||
)
|
||||
payload["evidence"] = (
|
||||
{
|
||||
"id": bundle.id,
|
||||
"uri": bundle.uri,
|
||||
"completeness": bundle.completeness,
|
||||
"size_bytes": bundle.size_bytes,
|
||||
}
|
||||
if bundle
|
||||
else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/events")
|
||||
async def get_events(run_id: str, after_seq: int = 0) -> List[Dict[str, object]]:
|
||||
async with session_scope() as session:
|
||||
exists = await session.get(Run, run_id)
|
||||
if exists is None:
|
||||
raise HTTPException(status_code=404, detail="Run 不存在")
|
||||
events = (
|
||||
await session.scalars(
|
||||
select(RunEvent)
|
||||
.where(RunEvent.run_id == run_id, RunEvent.seq > after_seq)
|
||||
.order_by(RunEvent.seq)
|
||||
)
|
||||
).all()
|
||||
return [event_to_dict(event) for event in events]
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/pause")
|
||||
async def pause_run(run_id: str, body: HumanActionBody) -> Dict[str, object]:
|
||||
async with session_scope() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run 不存在")
|
||||
if run.state != RunState.RUNNING.value:
|
||||
raise HTTPException(status_code=409, detail="只有 RUNNING 任务可以暂停")
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.HUMAN_TOUCH.value,
|
||||
"{0}: {1}".format(body.actor, body.reason),
|
||||
source=EventSource.HUMAN.value,
|
||||
projection={
|
||||
"human_touches": run.human_touches + 1,
|
||||
"unattended_completion": False,
|
||||
},
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_PAUSED.value,
|
||||
"任务已在安全检查点暂停",
|
||||
source=EventSource.HUMAN.value,
|
||||
target_state=RunState.PAUSED.value,
|
||||
)
|
||||
return run_to_dict(run)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/resume")
|
||||
async def resume_run(run_id: str, body: HumanActionBody) -> Dict[str, object]:
|
||||
async with session_scope() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run 不存在")
|
||||
if run.state != RunState.PAUSED.value:
|
||||
raise HTTPException(status_code=409, detail="只有 PAUSED 任务可以继续")
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.HUMAN_TOUCH.value,
|
||||
"{0}: {1}".format(body.actor, body.reason),
|
||||
source=EventSource.HUMAN.value,
|
||||
projection={
|
||||
"human_touches": run.human_touches + 1,
|
||||
"unattended_completion": False,
|
||||
},
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_RESUMED.value,
|
||||
"人工确认后从检查点继续",
|
||||
source=EventSource.HUMAN.value,
|
||||
target_state=RunState.RUNNING.value,
|
||||
)
|
||||
return run_to_dict(run)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/emergency-stop")
|
||||
async def emergency_stop(run_id: str, body: HumanActionBody) -> Dict[str, object]:
|
||||
async with session_scope() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run 不存在")
|
||||
if run.state in {
|
||||
RunState.COMPLETED.value,
|
||||
RunState.ABORTED.value,
|
||||
RunState.FROZEN.value,
|
||||
RunState.REJECTED.value,
|
||||
}:
|
||||
raise HTTPException(status_code=409, detail="终态任务不能再次停止")
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.HUMAN_TOUCH.value,
|
||||
"{0}: {1}".format(body.actor, body.reason),
|
||||
source=EventSource.HUMAN.value,
|
||||
severity=Severity.CRITICAL.value,
|
||||
projection={
|
||||
"human_touches": run.human_touches + 1,
|
||||
"unattended_completion": False,
|
||||
},
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_ABORTED.value,
|
||||
"紧急停止已执行,现场与检查点已保留",
|
||||
source=EventSource.HUMAN.value,
|
||||
severity=Severity.CRITICAL.value,
|
||||
target_state=RunState.ABORTED.value,
|
||||
projection={"ended_at": utcnow()},
|
||||
)
|
||||
await session.execute(
|
||||
ResourceLock.__table__.delete().where(ResourceLock.run_id == run.id)
|
||||
)
|
||||
payload = run_to_dict(run)
|
||||
cancel_simulation(run_id)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/dashboard/summary")
|
||||
async def dashboard_summary() -> Dict[str, object]:
|
||||
async with session_scope() as session:
|
||||
total = await session.scalar(select(func.count()).select_from(Run)) or 0
|
||||
running = (
|
||||
await session.scalar(
|
||||
select(func.count()).select_from(Run).where(
|
||||
Run.state.in_(
|
||||
[
|
||||
RunState.QUEUED.value,
|
||||
RunState.PREFLIGHT.value,
|
||||
RunState.RUNNING.value,
|
||||
RunState.RECOVERING.value,
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
completed = (
|
||||
await session.scalar(
|
||||
select(func.count()).select_from(Run).where(
|
||||
Run.state == RunState.COMPLETED.value
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
unattended = (
|
||||
await session.scalar(
|
||||
select(func.count()).select_from(Run).where(
|
||||
Run.state == RunState.COMPLETED.value,
|
||||
Run.unattended_completion.is_(True),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
ucr = round(unattended / completed * 100, 1) if completed else 0.0
|
||||
return {
|
||||
"total_runs": total,
|
||||
"active_runs": running,
|
||||
"completed_runs": completed,
|
||||
"unattended_completion_rate": ucr,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""开发与演示命令行。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from .db import drop_db, init_db, session_scope
|
||||
from .models import EvidenceBundle, Run
|
||||
from .schemas import CreateRunBody
|
||||
from .seed import ensure_seed_data
|
||||
from .services.runs import create_run, run_to_dict, simulate_run
|
||||
|
||||
|
||||
async def _init_db() -> None:
|
||||
await init_db()
|
||||
|
||||
|
||||
async def _seed() -> None:
|
||||
await init_db()
|
||||
async with session_scope() as session:
|
||||
await ensure_seed_data(session)
|
||||
|
||||
|
||||
async def _reset() -> None:
|
||||
await drop_db()
|
||||
await init_db()
|
||||
await _seed()
|
||||
|
||||
|
||||
async def _demo() -> None:
|
||||
await _seed()
|
||||
async with session_scope() as session:
|
||||
run = await create_run(
|
||||
session,
|
||||
CreateRunBody(loops=8, inject_failure=True, created_by="cli-demo"),
|
||||
)
|
||||
run_id = run.id
|
||||
print("Run {0} 已启动:将注入一次心跳丢失并自动升级到 L3 恢复。".format(run_id))
|
||||
await simulate_run(run_id)
|
||||
async with session_scope() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
bundle = await session.scalar(
|
||||
select(EvidenceBundle)
|
||||
.where(EvidenceBundle.run_id == run_id)
|
||||
.order_by(EvidenceBundle.created_at.desc())
|
||||
)
|
||||
assert run is not None
|
||||
payload = run_to_dict(run)
|
||||
print(
|
||||
"完成:{state} / {verdict},循环 {loop_done}/{loop_target},恢复 {recovery_count} 次。".format(
|
||||
**payload
|
||||
)
|
||||
)
|
||||
if bundle:
|
||||
print("Evidence Bundle: {0}".format(bundle.uri))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="flashops")
|
||||
parser.add_argument("command", choices=["init-db", "seed", "reset-db", "demo"])
|
||||
args = parser.parse_args()
|
||||
commands = {
|
||||
"init-db": _init_db,
|
||||
"seed": _seed,
|
||||
"reset-db": _reset,
|
||||
"demo": _demo,
|
||||
}
|
||||
asyncio.run(commands[args.command]())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""配置。
|
||||
|
||||
产品名只出现在这里和 apps/console/lib/brand.ts —— 改名成本 = 改两个常量。
|
||||
商标检索未完成前,任何地方都不要再硬编码产品名。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
APP_NAME = "FlashOps"
|
||||
APP_TAGLINE = "存储实验室无人值守执行层"
|
||||
|
||||
# 仓库根目录:.../flashops
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _env(key: str, default: str) -> str:
|
||||
return os.environ.get(f"FLASHOPS_{key}", default)
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
return int(os.environ.get(f"FLASHOPS_{key}", str(default)))
|
||||
|
||||
|
||||
def _env_float(key: str, default: float) -> float:
|
||||
return float(os.environ.get(f"FLASHOPS_{key}", str(default)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimingPolicy:
|
||||
"""时间策略。
|
||||
|
||||
全部集中在这里,且引擎不直接读它 —— 引擎从 Snapshot 拿数值。
|
||||
这样"30 秒心跳超时"这类规则可以在测试里用 0.03 秒验证。
|
||||
"""
|
||||
|
||||
heartbeat_interval_s: float = 5.0
|
||||
heartbeat_degraded_after_s: float = 15.0
|
||||
heartbeat_lost_after_s: float = 30.0
|
||||
lease_ttl_s: float = 60.0
|
||||
lease_poll_timeout_s: float = 20.0
|
||||
step_default_timeout_s: float = 600.0
|
||||
recovery_settle_s: float = 20.0 # 恢复动作后等待主机回来的时间
|
||||
ac_cycle_off_s: float = 10.0 # AC 断电后的安全间隔(方案 §7.2)
|
||||
max_recovery_per_loop: int = 3
|
||||
max_recovery_total: int = 20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
app_name: str = APP_NAME
|
||||
env: str = field(default_factory=lambda: _env("ENV", "development"))
|
||||
|
||||
# 开发态:SQLite。生产:postgresql+asyncpg://...
|
||||
database_url: str = field(
|
||||
default_factory=lambda: _env(
|
||||
"DATABASE_URL", "sqlite+aiosqlite:///" + str(REPO_ROOT / "var" / "flashops.db")
|
||||
)
|
||||
)
|
||||
# 开发态:本地目录。生产:s3://minio:9000/flashops
|
||||
object_store_url: str = field(
|
||||
default_factory=lambda: _env("OBJECT_STORE_URL", str(REPO_ROOT / "var" / "objects"))
|
||||
)
|
||||
# 开发态:进程内 asyncio。生产:redis://... / nats://...
|
||||
bus_url: Optional[str] = field(default_factory=lambda: os.environ.get("FLASHOPS_BUS_URL"))
|
||||
# Agent 首次注册口令。开发态未配置时允许本机演示;生产态缺失时拒绝注册。
|
||||
agent_enrollment_token: Optional[str] = field(
|
||||
default_factory=lambda: os.environ.get("FLASHOPS_AGENT_ENROLLMENT_TOKEN")
|
||||
)
|
||||
|
||||
api_host: str = field(default_factory=lambda: _env("API_HOST", "0.0.0.0"))
|
||||
api_port: int = field(default_factory=lambda: _env_int("API_PORT", 8000))
|
||||
cors_origins: str = field(default_factory=lambda: _env("CORS_ORIGINS", "http://localhost:3000"))
|
||||
|
||||
# 引擎主循环的 tick 间隔。demo/测试里调小以加速。
|
||||
engine_tick_s: float = field(default_factory=lambda: _env_float("ENGINE_TICK_S", 0.5))
|
||||
# 演示/仿真加速倍率:1.0 = 真实速度
|
||||
time_scale: float = field(default_factory=lambda: _env_float("TIME_SCALE", 1.0))
|
||||
|
||||
timing: TimingPolicy = field(default_factory=TimingPolicy)
|
||||
|
||||
@property
|
||||
def is_sqlite(self) -> bool:
|
||||
return self.database_url.startswith("sqlite")
|
||||
|
||||
@property
|
||||
def object_store_path(self) -> Path:
|
||||
return Path(self.object_store_url)
|
||||
|
||||
|
||||
_settings: Optional[Settings] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
|
||||
def reset_settings() -> None:
|
||||
"""测试用:让下一次 get_settings() 重新读环境变量。"""
|
||||
global _settings
|
||||
_settings = None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""异步数据库连接与会话。
|
||||
|
||||
连接按当前配置惰性创建,测试可以安全地切换到临时 SQLite 数据库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Optional
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from .config import get_settings
|
||||
from .models import Base
|
||||
|
||||
_engine: Optional[AsyncEngine] = None
|
||||
_engine_url: Optional[str] = None
|
||||
_session_factory: Optional[async_sessionmaker[AsyncSession]] = None
|
||||
_sqlite_session_locks: WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = (
|
||||
WeakKeyDictionary()
|
||||
)
|
||||
|
||||
|
||||
def _sqlite_session_lock() -> asyncio.Lock:
|
||||
"""Return one transaction lock per event loop for the SQLite prototype.
|
||||
|
||||
SQLite permits only one writer at a time. Serializing the complete
|
||||
session_scope transaction prevents the simulator and human control routes
|
||||
from reading the same event sequence and then racing on commit. PostgreSQL
|
||||
deployments skip this application-level lock.
|
||||
"""
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
lock = _sqlite_session_locks.get(loop)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_sqlite_session_locks[loop] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def get_engine() -> AsyncEngine:
|
||||
global _engine, _engine_url, _session_factory
|
||||
url = get_settings().database_url
|
||||
if _engine is None or _engine_url != url:
|
||||
_engine = create_async_engine(url, future=True)
|
||||
_engine_url = url
|
||||
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
get_engine()
|
||||
assert _session_factory is not None
|
||||
return _session_factory
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _transaction_scope() -> AsyncIterator[AsyncSession]:
|
||||
session = get_session_factory()()
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_scope() -> AsyncIterator[AsyncSession]:
|
||||
if get_settings().is_sqlite:
|
||||
async with _sqlite_session_lock():
|
||||
async with _transaction_scope() as session:
|
||||
yield session
|
||||
return
|
||||
|
||||
async with _transaction_scope() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
engine = get_engine()
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def drop_db() -> None:
|
||||
engine = get_engine()
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
async def dispose_db() -> None:
|
||||
global _engine, _engine_url, _session_factory
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_engine_url = None
|
||||
_session_factory = None
|
||||
@@ -0,0 +1,5 @@
|
||||
"""无 I/O 的编排规则。"""
|
||||
|
||||
from .states import IllegalTransition, assert_run_transition
|
||||
|
||||
__all__ = ["IllegalTransition", "assert_run_transition"]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Materialize a published Workflow snapshot into sequential RunStep rows.
|
||||
|
||||
The planner deliberately supports a small, deterministic subset of the workflow
|
||||
schema. It never evaluates expressions and it never turns free-form text into a
|
||||
command. Host Agents only receive already-published adapter/template references.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..enums import OnFail, StepType
|
||||
from ..models import Run, RunStep, Workflow
|
||||
|
||||
_PARAM = re.compile(r"^\{\{\s*params\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$")
|
||||
_LOOP_INDEX = re.compile(r"^\{\{\s*loop\.index\s*\}\}$")
|
||||
_HOST_STEP_TYPES = {
|
||||
StepType.COMMAND.value,
|
||||
StepType.DEVICE_CHECK.value,
|
||||
StepType.WAIT.value,
|
||||
}
|
||||
|
||||
|
||||
class WorkflowPlanningError(ValueError):
|
||||
"""The published workflow cannot be safely materialized."""
|
||||
|
||||
|
||||
def _resolve(value: Any, run_params: Dict[str, Any], loop_index: Optional[int]) -> Any:
|
||||
"""Resolve only exact, allow-listed template tokens; no expression evaluation."""
|
||||
|
||||
if isinstance(value, str):
|
||||
parameter = _PARAM.fullmatch(value)
|
||||
if parameter:
|
||||
key = parameter.group(1)
|
||||
if key not in run_params:
|
||||
raise WorkflowPlanningError("工作流参数不存在: {0}".format(key))
|
||||
return run_params[key]
|
||||
if _LOOP_INDEX.fullmatch(value):
|
||||
if loop_index is None:
|
||||
raise WorkflowPlanningError("loop.index 只能在循环体内使用")
|
||||
return loop_index
|
||||
if "{{" in value or "}}" in value:
|
||||
raise WorkflowPlanningError("模板只允许完整的 params.X 或 loop.index 占位符")
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {key: _resolve(item, run_params, loop_index) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_resolve(item, run_params, loop_index) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _step_row(
|
||||
*,
|
||||
run: Run,
|
||||
spec: Dict[str, Any],
|
||||
seq: int,
|
||||
loop_index: Optional[int],
|
||||
checkpoint_after: bool,
|
||||
) -> RunStep:
|
||||
step_type = str(spec.get("type", ""))
|
||||
if step_type not in _HOST_STEP_TYPES:
|
||||
raise WorkflowPlanningError("Host Agent 不支持步骤类型: {0}".format(step_type or "<empty>"))
|
||||
key = str(spec.get("key", "")).strip()
|
||||
adapter = str(spec.get("adapter", "")).strip()
|
||||
if not key or not adapter:
|
||||
raise WorkflowPlanningError("步骤 key 与 adapter 不能为空")
|
||||
|
||||
retry = int(spec.get("retry", 0))
|
||||
timeout_s = float(spec.get("timeout_s", get_settings().timing.step_default_timeout_s))
|
||||
if retry < 0 or retry > 20:
|
||||
raise WorkflowPlanningError("步骤 retry 必须在 0..20 之间")
|
||||
if timeout_s <= 0:
|
||||
raise WorkflowPlanningError("步骤 timeout_s 必须大于 0")
|
||||
|
||||
return RunStep(
|
||||
run_id=run.id,
|
||||
seq=seq,
|
||||
loop_index=loop_index,
|
||||
step_key=key,
|
||||
step_type=step_type,
|
||||
adapter=adapter,
|
||||
template=spec.get("template"),
|
||||
params=_resolve(dict(spec.get("params") or {}), run.params, loop_index),
|
||||
max_retry=retry,
|
||||
timeout_s=timeout_s,
|
||||
idempotent=bool(spec.get("idempotent", False)),
|
||||
danger=str(spec.get("danger", "none")),
|
||||
on_fail=str(spec.get("on_fail", OnFail.RETRY.value)),
|
||||
checkpoint_after=checkpoint_after,
|
||||
)
|
||||
|
||||
|
||||
def plan_run_steps(run: Run, workflow: Workflow) -> List[RunStep]:
|
||||
"""Expand one workflow snapshot into the exact ordered steps for a Run."""
|
||||
|
||||
if not workflow.published:
|
||||
raise WorkflowPlanningError("工作流尚未发布")
|
||||
raw_steps = workflow.spec.get("steps") if isinstance(workflow.spec, dict) else None
|
||||
if not isinstance(raw_steps, list) or not raw_steps:
|
||||
raise WorkflowPlanningError("工作流没有可执行步骤")
|
||||
|
||||
planned: List[RunStep] = []
|
||||
seq = 1
|
||||
for raw in raw_steps:
|
||||
if not isinstance(raw, dict):
|
||||
raise WorkflowPlanningError("工作流步骤必须是 object")
|
||||
step_type = str(raw.get("type", ""))
|
||||
|
||||
# Safety preflight and evidence/report generation live in the control plane.
|
||||
if step_type in {StepType.PRECHECK.value, StepType.REPORT.value}:
|
||||
continue
|
||||
|
||||
if step_type == StepType.LOOP.value:
|
||||
body = raw.get("body")
|
||||
if not isinstance(body, list) or not body:
|
||||
raise WorkflowPlanningError("循环步骤必须包含非空 body")
|
||||
for loop_index in range(1, run.loop_target + 1):
|
||||
for index, child in enumerate(body):
|
||||
if not isinstance(child, dict):
|
||||
raise WorkflowPlanningError("循环体步骤必须是 object")
|
||||
planned.append(
|
||||
_step_row(
|
||||
run=run,
|
||||
spec=child,
|
||||
seq=seq,
|
||||
loop_index=loop_index,
|
||||
checkpoint_after=bool(child.get("checkpoint", False))
|
||||
or (bool(raw.get("checkpoint", False)) and index == len(body) - 1),
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
continue
|
||||
|
||||
planned.append(
|
||||
_step_row(
|
||||
run=run,
|
||||
spec=raw,
|
||||
seq=seq,
|
||||
loop_index=None,
|
||||
checkpoint_after=bool(raw.get("checkpoint", False)),
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
|
||||
if not planned:
|
||||
raise WorkflowPlanningError("工作流没有 Host Agent 可执行步骤")
|
||||
return planned
|
||||
|
||||
|
||||
async def materialize_run_steps(
|
||||
session: AsyncSession, run: Run, workflow: Workflow
|
||||
) -> List[RunStep]:
|
||||
planned = plan_run_steps(run, workflow)
|
||||
session.add_all(planned)
|
||||
await session.flush()
|
||||
return planned
|
||||
@@ -0,0 +1,37 @@
|
||||
"""五级恢复阶梯的纯函数决策。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..enums import RecoveryLevel, RecoveryTrigger
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryContext:
|
||||
trigger: RecoveryTrigger
|
||||
attempt: int
|
||||
agent_online: bool
|
||||
host_network_online: bool
|
||||
oob_online: bool
|
||||
|
||||
|
||||
def next_recovery_level(context: RecoveryContext) -> RecoveryLevel:
|
||||
if context.trigger == RecoveryTrigger.DATA_INTEGRITY:
|
||||
return RecoveryLevel.FREEZE
|
||||
|
||||
ladder = []
|
||||
if context.agent_online:
|
||||
ladder.append(RecoveryLevel.AGENT_SOFT)
|
||||
if context.host_network_online:
|
||||
ladder.append(RecoveryLevel.OS_REBOOT)
|
||||
if context.oob_online:
|
||||
ladder.extend(
|
||||
[
|
||||
RecoveryLevel.OOB_RESET,
|
||||
RecoveryLevel.OOB_ATX_POWER,
|
||||
RecoveryLevel.OOB_AC_CYCLE,
|
||||
]
|
||||
)
|
||||
if context.attempt >= len(ladder):
|
||||
return RecoveryLevel.FREEZE
|
||||
return ladder[context.attempt]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Run 状态转移表。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, FrozenSet
|
||||
|
||||
from ..enums import RunState
|
||||
|
||||
|
||||
class IllegalTransition(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
RUN_TRANSITIONS: Dict[RunState, FrozenSet[RunState]] = {
|
||||
RunState.QUEUED: frozenset({RunState.PREFLIGHT, RunState.ABORTED}),
|
||||
RunState.PREFLIGHT: frozenset(
|
||||
{RunState.RUNNING, RunState.REJECTED, RunState.ABORTED}
|
||||
),
|
||||
RunState.RUNNING: frozenset(
|
||||
{
|
||||
RunState.RECOVERING,
|
||||
RunState.PAUSED,
|
||||
RunState.FROZEN,
|
||||
RunState.COMPLETED,
|
||||
RunState.ABORTED,
|
||||
}
|
||||
),
|
||||
RunState.RECOVERING: frozenset(
|
||||
{RunState.RUNNING, RunState.FROZEN, RunState.ABORTED}
|
||||
),
|
||||
RunState.PAUSED: frozenset({RunState.RUNNING, RunState.ABORTED}),
|
||||
RunState.FROZEN: frozenset(),
|
||||
RunState.COMPLETED: frozenset(),
|
||||
RunState.ABORTED: frozenset(),
|
||||
RunState.REJECTED: frozenset(),
|
||||
}
|
||||
|
||||
|
||||
def assert_run_transition(current: str, target: str) -> None:
|
||||
current_state = RunState(current)
|
||||
target_state = RunState(target)
|
||||
if target_state not in RUN_TRANSITIONS[current_state]:
|
||||
raise IllegalTransition(
|
||||
"Run 状态不允许从 {0} 转到 {1}".format(current_state.value, target_state.value)
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""全部领域枚举。
|
||||
|
||||
放在包根、不依赖任何东西 —— models / engine / schemas / api 都从这里取,
|
||||
保证"状态"这个概念在全仓库只有一份定义。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
"""Python 3.9 没有 enum.StrEnum,自己来一个。"""
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - 只影响日志可读性
|
||||
return self.value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Run / Step
|
||||
|
||||
class RunState(StrEnum):
|
||||
QUEUED = "QUEUED" # 等资源
|
||||
PREFLIGHT = "PREFLIGHT" # 安全门禁 + 环境指纹冻结
|
||||
RUNNING = "RUNNING"
|
||||
RECOVERING = "RECOVERING" # 恢复阶梯进行中
|
||||
PAUSED = "PAUSED" # 人工暂停
|
||||
FROZEN = "FROZEN" # 冻结现场,停止一切覆盖性动作(终态)
|
||||
COMPLETED = "COMPLETED" # 终态
|
||||
ABORTED = "ABORTED" # 紧急停止(终态)
|
||||
REJECTED = "REJECTED" # 安全门禁拒绝(终态)
|
||||
|
||||
|
||||
class StepState(StrEnum):
|
||||
PENDING = "PENDING"
|
||||
DISPATCHED = "DISPATCHED" # 已租给 Agent,等确认接手
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
TIMED_OUT = "TIMED_OUT"
|
||||
SKIPPED = "SKIPPED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class Verdict(StrEnum):
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
INCONCLUSIVE = "INCONCLUSIVE" # 非幂等步骤中断后不自动重跑 → 需人工确认
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 恢复阶梯
|
||||
|
||||
class RecoveryLevel(StrEnum):
|
||||
"""方案 §7.2 的五级阶梯。顺序即升级顺序。"""
|
||||
|
||||
AGENT_SOFT = "L1_AGENT_SOFT" # Agent 优雅停止 / 软重启进程
|
||||
OS_REBOOT = "L2_OS_REBOOT" # OS 远程通道重启
|
||||
OOB_RESET = "L3_OOB_RESET" # 带外触发主板 Reset
|
||||
OOB_ATX_POWER = "L4_OOB_ATX_POWER" # 带外模拟 ATX 长按关机再开机
|
||||
OOB_AC_CYCLE = "L5_OOB_AC_CYCLE" # 整机 AC 断电 → 安全间隔 → 上电
|
||||
FREEZE = "FREEZE" # 冻结现场,不再自动恢复
|
||||
|
||||
@property
|
||||
def requires_oob(self) -> bool:
|
||||
return self in (
|
||||
RecoveryLevel.OOB_RESET,
|
||||
RecoveryLevel.OOB_ATX_POWER,
|
||||
RecoveryLevel.OOB_AC_CYCLE,
|
||||
)
|
||||
|
||||
|
||||
class RecoveryOutcome(StrEnum):
|
||||
RECOVERED = "RECOVERED"
|
||||
FAILED = "FAILED"
|
||||
ESCALATED = "ESCALATED"
|
||||
FROZEN = "FROZEN"
|
||||
|
||||
|
||||
class RecoveryTrigger(StrEnum):
|
||||
HEARTBEAT_LOST = "HEARTBEAT_LOST"
|
||||
STEP_TIMEOUT = "STEP_TIMEOUT"
|
||||
HOST_CRASH = "HOST_CRASH" # 蓝屏 / kernel panic
|
||||
DUT_NOT_ENUMERATED = "DUT_NOT_ENUMERATED"
|
||||
DATA_INTEGRITY = "DATA_INTEGRITY" # → 直接 FREEZE,不走阶梯
|
||||
MANUAL = "MANUAL"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- 资产
|
||||
|
||||
class HostStatus(StrEnum):
|
||||
ONLINE = "ONLINE"
|
||||
DEGRADED = "DEGRADED" # 心跳迟到但未判失联
|
||||
OFFLINE = "OFFLINE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class DutStatus(StrEnum):
|
||||
IDLE = "IDLE"
|
||||
IN_USE = "IN_USE"
|
||||
QUARANTINED = "QUARANTINED" # 疑似坏盘,隔离待人工确认
|
||||
MISSING = "MISSING" # 探针找不到
|
||||
|
||||
|
||||
class PowerState(StrEnum):
|
||||
ON = "ON"
|
||||
OFF = "OFF"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class DangerLevel(StrEnum):
|
||||
NONE = "none"
|
||||
LOW = "low"
|
||||
HIGH = "high" # 需要审批记录
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- 失败
|
||||
|
||||
class FailureClass(StrEnum):
|
||||
"""INFRA_FAILURE 必须与 DUT_DEFECT 分开统计 —— 见 domain-model.md。
|
||||
|
||||
把网络断了算成 SSD 缺陷,客户第一周就不信任这套系统。
|
||||
"""
|
||||
|
||||
DUT_DEFECT = "DUT_DEFECT"
|
||||
INFRA_FAILURE = "INFRA_FAILURE"
|
||||
SCRIPT_FAILURE = "SCRIPT_FAILURE"
|
||||
DATA_INTEGRITY = "DATA_INTEGRITY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class IntegrityState(StrEnum):
|
||||
OK = "OK"
|
||||
MISMATCH = "MISMATCH"
|
||||
NOT_CHECKED = "NOT_CHECKED"
|
||||
|
||||
|
||||
# ------------------------------------------------------------- 事件(时间线)
|
||||
|
||||
class EventSource(StrEnum):
|
||||
CONTROL_PLANE = "control_plane"
|
||||
AGENT = "agent"
|
||||
OOB = "oob" # 带外通道 —— 主机死后唯一还在说话的
|
||||
HUMAN = "human"
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
|
||||
class EventKind(StrEnum):
|
||||
"""统一时间线的事件种类。
|
||||
|
||||
新增事件种类是常事;删除/改名不是 —— 历史事件已经落库,改名会让旧 Run 的
|
||||
时间线读不出来。只增不改。
|
||||
"""
|
||||
|
||||
# Run 生命周期
|
||||
RUN_CREATED = "RUN_CREATED"
|
||||
RUN_QUEUED = "RUN_QUEUED"
|
||||
RUN_PREFLIGHT_STARTED = "RUN_PREFLIGHT_STARTED"
|
||||
RUN_PREFLIGHT_PASSED = "RUN_PREFLIGHT_PASSED"
|
||||
RUN_REJECTED = "RUN_REJECTED"
|
||||
RUN_STARTED = "RUN_STARTED"
|
||||
RUN_PAUSED = "RUN_PAUSED"
|
||||
RUN_RESUMED = "RUN_RESUMED"
|
||||
RUN_COMPLETED = "RUN_COMPLETED"
|
||||
RUN_ABORTED = "RUN_ABORTED"
|
||||
RUN_FROZEN = "RUN_FROZEN"
|
||||
|
||||
# 循环与步骤
|
||||
LOOP_STARTED = "LOOP_STARTED"
|
||||
LOOP_COMPLETED = "LOOP_COMPLETED"
|
||||
STEP_DISPATCHED = "STEP_DISPATCHED"
|
||||
STEP_LEASE_RENEWED = "STEP_LEASE_RENEWED"
|
||||
STEP_LEASE_EXPIRED = "STEP_LEASE_EXPIRED"
|
||||
STEP_STARTED = "STEP_STARTED"
|
||||
STEP_OUTPUT = "STEP_OUTPUT"
|
||||
STEP_SUCCEEDED = "STEP_SUCCEEDED"
|
||||
STEP_FAILED = "STEP_FAILED"
|
||||
STEP_TIMED_OUT = "STEP_TIMED_OUT"
|
||||
STEP_RETRYING = "STEP_RETRYING"
|
||||
STEP_CANCELLED = "STEP_CANCELLED"
|
||||
|
||||
# 检查点
|
||||
CHECKPOINT_SAVED = "CHECKPOINT_SAVED"
|
||||
CHECKPOINT_RESUMED = "CHECKPOINT_RESUMED"
|
||||
|
||||
# 心跳与双通道观测
|
||||
HEARTBEAT_DEGRADED = "HEARTBEAT_DEGRADED"
|
||||
HEARTBEAT_LOST = "HEARTBEAT_LOST"
|
||||
HEARTBEAT_RECOVERED = "HEARTBEAT_RECOVERED"
|
||||
OOB_OBSERVATION = "OOB_OBSERVATION"
|
||||
OOB_POWER_STATE = "OOB_POWER_STATE"
|
||||
|
||||
# 恢复
|
||||
RECOVERY_STARTED = "RECOVERY_STARTED"
|
||||
RECOVERY_ESCALATED = "RECOVERY_ESCALATED"
|
||||
RECOVERY_SUCCEEDED = "RECOVERY_SUCCEEDED"
|
||||
RECOVERY_FAILED = "RECOVERY_FAILED"
|
||||
|
||||
# 设备与数据
|
||||
DUT_ENUMERATION_LOST = "DUT_ENUMERATION_LOST"
|
||||
DUT_ENUMERATION_RESTORED = "DUT_ENUMERATION_RESTORED"
|
||||
INTEGRITY_MISMATCH = "INTEGRITY_MISMATCH"
|
||||
|
||||
# 安全与资源
|
||||
SAFETY_APPROVED = "SAFETY_APPROVED"
|
||||
SAFETY_REJECTED = "SAFETY_REJECTED"
|
||||
LOCK_ACQUIRED = "LOCK_ACQUIRED"
|
||||
LOCK_RELEASED = "LOCK_RELEASED"
|
||||
|
||||
# 人与产物
|
||||
HUMAN_TOUCH = "HUMAN_TOUCH" # 每一次人工介入都记 —— UCR 的分母来源
|
||||
FAILURE_SIGNED = "FAILURE_SIGNED"
|
||||
EVIDENCE_BUNDLED = "EVIDENCE_BUNDLED"
|
||||
|
||||
|
||||
class StepType(StrEnum):
|
||||
COMMAND = "command"
|
||||
DEVICE_CHECK = "device_check"
|
||||
WAIT = "wait"
|
||||
LOOP = "loop"
|
||||
PRECHECK = "precheck"
|
||||
REPORT = "report"
|
||||
|
||||
|
||||
class OnFail(StrEnum):
|
||||
RETRY = "retry"
|
||||
FAIL_RUN = "fail_run"
|
||||
CONTINUE = "continue"
|
||||
FREEZE = "freeze"
|
||||
RECOVER = "recover"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""事件溯源写入。"""
|
||||
|
||||
from .recorder import append_event
|
||||
|
||||
__all__ = ["append_event"]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Run 事件唯一写入口,同时维护当前状态投影。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..engine.states import assert_run_transition
|
||||
from ..enums import EventSource, Severity
|
||||
from ..models import Run, RunEvent, utcnow
|
||||
|
||||
|
||||
async def append_event(
|
||||
session: AsyncSession,
|
||||
run: Run,
|
||||
kind: str,
|
||||
message: str,
|
||||
*,
|
||||
source: str = EventSource.CONTROL_PLANE.value,
|
||||
severity: str = Severity.INFO.value,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
target_state: Optional[str] = None,
|
||||
projection: Optional[Dict[str, Any]] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
step_id: Optional[str] = None,
|
||||
loop_index: Optional[int] = None,
|
||||
) -> RunEvent:
|
||||
if target_state is not None and target_state != run.state:
|
||||
assert_run_transition(run.state, target_state)
|
||||
|
||||
run.event_seq += 1
|
||||
event = RunEvent(
|
||||
run_id=run.id,
|
||||
seq=run.event_seq,
|
||||
ts=utcnow(),
|
||||
source=source,
|
||||
kind=kind,
|
||||
severity=severity,
|
||||
message=message,
|
||||
payload=payload or {},
|
||||
idempotency_key=idempotency_key,
|
||||
step_id=step_id,
|
||||
loop_index=loop_index,
|
||||
)
|
||||
session.add(event)
|
||||
|
||||
if target_state is not None:
|
||||
run.state = target_state
|
||||
for key, value in (projection or {}).items():
|
||||
if not hasattr(run, key):
|
||||
raise AttributeError("Run 不存在投影字段: {0}".format(key))
|
||||
setattr(run, key, value)
|
||||
await session.flush()
|
||||
return event
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Evidence Bundle 生成。"""
|
||||
|
||||
from .bundle import build_evidence_bundle
|
||||
|
||||
__all__ = ["build_evidence_bundle"]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""把一次 Run 的可重放事件打成最小证据包。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models import EvidenceBundle, Run, RunEvent, iso_z
|
||||
|
||||
|
||||
async def build_evidence_bundle(session: AsyncSession, run: Run) -> EvidenceBundle:
|
||||
events = (
|
||||
await session.scalars(
|
||||
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
|
||||
)
|
||||
).all()
|
||||
root = get_settings().object_store_path / "runs" / run.id
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
timeline_path = root / "events.ndjson"
|
||||
timeline = "\n".join(
|
||||
json.dumps(
|
||||
{
|
||||
"seq": event.seq,
|
||||
"ts": iso_z(event.ts),
|
||||
"source": event.source,
|
||||
"kind": event.kind,
|
||||
"severity": event.severity,
|
||||
"message": event.message,
|
||||
"payload": event.payload,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
for event in events
|
||||
)
|
||||
timeline_path.write_text(timeline + ("\n" if timeline else ""), encoding="utf-8")
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"run_id": run.id,
|
||||
"workflow": {
|
||||
"key": run.workflow_key,
|
||||
"version": run.workflow_version,
|
||||
"spec_hash": run.spec_hash,
|
||||
},
|
||||
"environment_fingerprint": run.env_fingerprint,
|
||||
"checkpoint": run.checkpoint,
|
||||
"event_count": len(events),
|
||||
"files": [{"path": "events.ndjson", "bytes": timeline_path.stat().st_size}],
|
||||
}
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
size = manifest_path.stat().st_size + timeline_path.stat().st_size
|
||||
bundle = EvidenceBundle(
|
||||
run_id=run.id,
|
||||
uri=str(root),
|
||||
manifest=manifest,
|
||||
size_bytes=size,
|
||||
completeness=1.0,
|
||||
missing_fields=[],
|
||||
)
|
||||
session.add(bundle)
|
||||
await session.flush()
|
||||
return bundle
|
||||
@@ -0,0 +1,53 @@
|
||||
"""FlashOps FastAPI 应用。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .api.agents import router as agent_router
|
||||
from .api.routes import router
|
||||
from .config import APP_NAME, APP_TAGLINE, REPO_ROOT, get_settings
|
||||
from .db import init_db, session_scope
|
||||
from .seed import ensure_seed_data
|
||||
from .services.agent_status import run_agent_watchdog, stop_agent_watchdog
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
await init_db()
|
||||
async with session_scope() as session:
|
||||
await ensure_seed_data(session)
|
||||
watchdog = asyncio.create_task(run_agent_watchdog(), name="agent-heartbeat-watchdog")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_agent_watchdog(watchdog)
|
||||
|
||||
|
||||
app = FastAPI(title=APP_NAME, description=APP_TAGLINE, version="0.1.0", lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[origin.strip() for origin in get_settings().cors_origins.split(",")],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(router)
|
||||
app.include_router(agent_router)
|
||||
|
||||
console_dir = REPO_ROOT.parent / "前端UI八页面完成"
|
||||
if console_dir.exists():
|
||||
app.mount("/console", StaticFiles(directory=str(console_dir), html=True), name="console")
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root() -> RedirectResponse:
|
||||
if console_dir.exists():
|
||||
return RedirectResponse("/console/Dashboard.dc.html")
|
||||
return RedirectResponse("/docs")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""11 张表 → 方案 §5.3 的 7 个核心领域对象。映射见 docs/domain-model.md。"""
|
||||
from .analysis import AuditLog, EvidenceBundle, FailureSignature, RunFailure
|
||||
from .assets import Dut, FirmwareArtifact, OobController, TestHost
|
||||
from .base import Base, iso_z, new_id, utcnow
|
||||
from .run import RecoveryAction, ResourceLock, Run, RunEvent, RunStep
|
||||
from .workflow import Workflow
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"utcnow",
|
||||
"new_id",
|
||||
"iso_z",
|
||||
# 资产
|
||||
"TestHost",
|
||||
"OobController",
|
||||
"Dut",
|
||||
"FirmwareArtifact",
|
||||
# 工作流
|
||||
"Workflow",
|
||||
# Run
|
||||
"Run",
|
||||
"RunStep",
|
||||
"RunEvent",
|
||||
"RecoveryAction",
|
||||
"ResourceLock",
|
||||
# 分析
|
||||
"FailureSignature",
|
||||
"RunFailure",
|
||||
"EvidenceBundle",
|
||||
"AuditLog",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""失败签名、失败实例、Evidence Bundle、审计。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..enums import FailureClass, IntegrityState
|
||||
from .base import Base, TimestampMixin, pk
|
||||
|
||||
|
||||
class FailureSignature(Base, TimestampMixin):
|
||||
"""失败签名 —— 聚类锚点。
|
||||
|
||||
8 要素哈希(方案 §8.3)。只用错误码会把"掉盘"和"脚本超时"归成一类,
|
||||
聚类就废了;用原始日志则每条日志都是新签名。所以用模板化后的日志指纹。
|
||||
"""
|
||||
|
||||
__tablename__ = "failure_signatures"
|
||||
__table_args__ = (UniqueConstraint("hash", name="uq_signature_hash"),)
|
||||
|
||||
id: Mapped[str] = pk("sig")
|
||||
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# 8 要素
|
||||
workflow_step: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
error_codes: Mapped[List[Any]] = mapped_column(JSON, default=list)
|
||||
log_templates: Mapped[List[Any]] = mapped_column(JSON, default=list)
|
||||
host_state: Mapped[str] = mapped_column(String(32), default="UNKNOWN")
|
||||
dut_enumeration_state: Mapped[str] = mapped_column(String(32), default="UNKNOWN")
|
||||
data_integrity_state: Mapped[str] = mapped_column(String(16), default=IntegrityState.NOT_CHECKED.value)
|
||||
recovery_outcome: Mapped[Optional[str]] = mapped_column(String(16))
|
||||
environment_fingerprint_hash: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
failure_class: Mapped[str] = mapped_column(String(24), default=FailureClass.UNKNOWN.value)
|
||||
occurrences: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
first_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
last_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
|
||||
# 人工确认后的处置:如已提单号、已知问题、误报
|
||||
triage_note: Mapped[Optional[str]] = mapped_column(Text)
|
||||
issue_ref: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
|
||||
class RunFailure(Base):
|
||||
"""一次具体的失败实例,挂在签名下面。"""
|
||||
|
||||
__tablename__ = "run_failures"
|
||||
__table_args__ = (Index("ix_failure_run", "run_id"),)
|
||||
|
||||
id: Mapped[str] = pk("fail")
|
||||
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
|
||||
signature_id: Mapped[Optional[str]] = mapped_column(ForeignKey("failure_signatures.id"))
|
||||
step_id: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
loop_index: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
failure_class: Mapped[str] = mapped_column(String(24), default=FailureClass.UNKNOWN.value)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
detail: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
# 记录固件版本,A/B 对比直接从这里聚合
|
||||
firmware_label: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
|
||||
class EvidenceBundle(Base, TimestampMixin):
|
||||
"""证据包。completeness < 1 说明有关键字段缺失 —— MVP 硬指标是 ≥95%。"""
|
||||
|
||||
__tablename__ = "evidence_bundles"
|
||||
|
||||
id: Mapped[str] = pk("bundle")
|
||||
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
|
||||
uri: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
manifest: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
completeness: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
missing_fields: Mapped[List[Any]] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""审计:谁、什么时候、用哪个模板版本、对什么、做了什么、结果如何。
|
||||
|
||||
方案 §6.6 的硬要求。与决策放在同一处 —— 见 ADR-0004。
|
||||
"""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
__table_args__ = (Index("ix_audit_ts", "ts"),)
|
||||
|
||||
id: Mapped[str] = pk("audit")
|
||||
ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
|
||||
actor: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
target_type: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
target_id: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
command_template: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
template_version: Mapped[Optional[str]] = mapped_column(String(16))
|
||||
params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
result: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
approved_by: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
reason: Mapped[Optional[str]] = mapped_column(Text)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""资产:测试主机、带外控制器、DUT、固件包。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..enums import DutStatus, HostStatus, PowerState
|
||||
from .base import Base, TimestampMixin, pk
|
||||
|
||||
|
||||
class OobController(Base, TimestampMixin):
|
||||
"""带外控制器。
|
||||
|
||||
独立成表而不是挂在 TestHost 上,因为它的核心价值就是**主机死后独活**:
|
||||
它有自己的网络、自己的心跳、自己的生命周期。主机 OFFLINE 时它必须还 ONLINE,
|
||||
这个"矛盾"状态是恢复阶梯 L3-L5 可用性的判断依据。
|
||||
"""
|
||||
|
||||
__tablename__ = "oob_controllers"
|
||||
|
||||
id: Mapped[str] = pk("oob")
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(32), default="simulated") # raspberry_pi / jetkvm / pdu / simulated
|
||||
endpoint: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
# 能力位:{"power": true, "reset": true, "atx": true, "ac": true, "hdmi": true, "temp": true}
|
||||
capabilities: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(16), default=HostStatus.UNKNOWN.value)
|
||||
power_state: Mapped[str] = mapped_column(String(16), default=PowerState.UNKNOWN.value)
|
||||
last_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
temperature_c: Mapped[Optional[float]] = mapped_column()
|
||||
|
||||
|
||||
class TestHost(Base, TimestampMixin):
|
||||
"""测试主机。环境指纹的主要来源 —— A/B 对比的可比性靠它固定。"""
|
||||
|
||||
__tablename__ = "test_hosts"
|
||||
|
||||
id: Mapped[str] = pk("host")
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
|
||||
|
||||
os_family: Mapped[str] = mapped_column(String(32), default="linux") # linux / windows
|
||||
os_version: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
kernel: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
cpu: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
motherboard: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
bios_version: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
agent_version: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
status: Mapped[str] = mapped_column(String(16), default=HostStatus.UNKNOWN.value)
|
||||
last_heartbeat_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
agent_id: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
# 只保存 bearer token 的 SHA-256 摘要;原始 token 仅在注册响应中返回一次。
|
||||
agent_token: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
oob_controller_id: Mapped[Optional[str]] = mapped_column(ForeignKey("oob_controllers.id"))
|
||||
# 已安装工具与版本,来自 Agent 的 discover():{"nvme-cli": "2.8", "fio": "3.36"}
|
||||
toolchain: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
labels: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class Dut(Base, TimestampMixin):
|
||||
"""被测设备。
|
||||
|
||||
allow_destructive 是防误盘的第二道信号(第一道是序列号,第三道是系统盘检测)。
|
||||
它不给普通 API 改 —— 只能走带审批记录的资产接口,见 ADR-0004。
|
||||
"""
|
||||
|
||||
__tablename__ = "duts"
|
||||
__table_args__ = (UniqueConstraint("serial", name="uq_dut_serial"),)
|
||||
|
||||
id: Mapped[str] = pk("dut")
|
||||
serial: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
model: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
vendor: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
capacity_gb: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
form_factor: Mapped[str] = mapped_column(String(32), default="M.2") # M.2 / U.2 / E1.S
|
||||
interface: Mapped[str] = mapped_column(String(32), default="NVMe")
|
||||
|
||||
current_firmware: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
bdf: Mapped[Optional[str]] = mapped_column(String(32)) # PCIe 地址,如 0000:03:00.0
|
||||
device_path: Mapped[Optional[str]] = mapped_column(String(64)) # /dev/nvme0n1
|
||||
|
||||
test_host_id: Mapped[Optional[str]] = mapped_column(ForeignKey("test_hosts.id"))
|
||||
status: Mapped[str] = mapped_column(String(16), default=DutStatus.IDLE.value)
|
||||
|
||||
# 破坏性测试白名单标记。默认 False —— 安全默认拒绝。
|
||||
allow_destructive: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
health: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) # SMART 关键项、温度、寿命
|
||||
labels: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class FirmwareArtifact(Base, TimestampMixin):
|
||||
"""固件包。哈希 + 签名 + 适用型号,缺一样就不可审计。"""
|
||||
|
||||
__tablename__ = "firmware_artifacts"
|
||||
|
||||
id: Mapped[str] = pk("fw")
|
||||
version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(64)) # "A" / "B" / "候选版"
|
||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
signature: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
applicable_models: Mapped[List[Any]] = mapped_column(JSON, default=list)
|
||||
flash_tool: Mapped[str] = mapped_column(String(64), default="nvme_cli")
|
||||
file_uri: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
uploaded_by: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
@@ -0,0 +1,64 @@
|
||||
"""ORM 基类与通用字段。
|
||||
|
||||
刻意保持"贫血模型":这些类只管持久化,不放业务逻辑。
|
||||
业务逻辑在 engine/(纯函数)与 services/(编排),这样才能不起数据库单测。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy import DateTime, JSON, String
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有表的基类。
|
||||
|
||||
type_annotation_map 里只用方言中立的类型:SQLite(开发)与 PostgreSQL(生产)
|
||||
行为一致。不要引入 JSONB / ARRAY / UUID 这些 PG 特有类型 —— 见 ADR-0001。
|
||||
"""
|
||||
|
||||
type_annotation_map = {
|
||||
Dict[str, Any]: JSON,
|
||||
dict: JSON,
|
||||
list: JSON,
|
||||
}
|
||||
|
||||
|
||||
def utcnow() -> _dt.datetime:
|
||||
"""统一的当前时间:naive UTC。
|
||||
|
||||
SQLite 不保存时区,所以全仓库统一存 naive UTC,序列化时再补 Z。
|
||||
禁止在任何地方用 datetime.now()(本地时区)—— 实验室里跨时区协作会出事。
|
||||
"""
|
||||
return _dt.datetime.utcnow()
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
"""带前缀的 ID,如 run_7f3a9c2e...。
|
||||
|
||||
前缀让日志和时间线一眼可读 —— 排障时不用回表查这个 ID 是什么东西。
|
||||
"""
|
||||
return "{0}_{1}".format(prefix, uuid.uuid4().hex[:20])
|
||||
|
||||
|
||||
def iso_z(value: Any) -> Any:
|
||||
"""naive UTC datetime → ISO8601 带 Z。非 datetime 原样返回。"""
|
||||
if isinstance(value, _dt.datetime):
|
||||
return value.replace(microsecond=value.microsecond).isoformat() + "Z"
|
||||
return value
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[_dt.datetime] = mapped_column(DateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[_dt.datetime] = mapped_column(
|
||||
DateTime, default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
def pk(prefix: str) -> Mapped[str]:
|
||||
return mapped_column(
|
||||
String(32), primary_key=True, default=lambda: new_id(prefix)
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Run 及其行为记录。
|
||||
|
||||
runs / run_steps / recovery_actions 是**读模型**(投影)。
|
||||
唯一真相是 run_events(append-only)—— 见 ADR-0002。
|
||||
|
||||
仓储层刻意不提供 update_run_state():改状态的唯一入口是 events/recorder.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..enums import EventSource, RunState, Severity, StepState
|
||||
from .base import Base, TimestampMixin, pk
|
||||
|
||||
|
||||
class Run(Base, TimestampMixin):
|
||||
__tablename__ = "runs"
|
||||
|
||||
id: Mapped[str] = pk("run")
|
||||
name: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
workflow_id: Mapped[str] = mapped_column(ForeignKey("workflows.id"), nullable=False)
|
||||
workflow_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
workflow_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
spec_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
state: Mapped[str] = mapped_column(String(16), default=RunState.QUEUED.value, nullable=False)
|
||||
verdict: Mapped[Optional[str]] = mapped_column(String(16))
|
||||
|
||||
dut_id: Mapped[Optional[str]] = mapped_column(ForeignKey("duts.id"))
|
||||
test_host_id: Mapped[Optional[str]] = mapped_column(ForeignKey("test_hosts.id"))
|
||||
firmware_a_id: Mapped[Optional[str]] = mapped_column(ForeignKey("firmware_artifacts.id"))
|
||||
firmware_b_id: Mapped[Optional[str]] = mapped_column(ForeignKey("firmware_artifacts.id"))
|
||||
|
||||
params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
# 任务开始时冻结的不可变快照。固件之外任何一项变了,A/B 对比结论就不成立。
|
||||
env_fingerprint: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
env_fingerprint_hash: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
loop_target: Mapped[int] = mapped_column(Integer, default=1)
|
||||
loop_done: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# 最近一次检查点:{"loop_index": 37, "next_step_key": "...", "dut_fw": "A"}
|
||||
checkpoint: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
started_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
|
||||
# ROI 凭据(方案 §9.3):这两个字段是销售武器,从第一行代码就记。
|
||||
unattended_completion: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
human_touches: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
recovery_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
# 事件序号分配器(单点递增,保证时间线可重放,见 ADR-0002)
|
||||
event_seq: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
created_by: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
freeze_reason: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
|
||||
class RunStep(Base, TimestampMixin):
|
||||
"""物化后的步骤实例。循环体在物化时按 loop_index 展开。"""
|
||||
|
||||
__tablename__ = "run_steps"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "seq", name="uq_step_run_seq"),
|
||||
Index("ix_step_run_state", "run_id", "state"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = pk("step")
|
||||
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
loop_index: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
step_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
step_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
adapter: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
template: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
state: Mapped[str] = mapped_column(String(16), default=StepState.PENDING.value, nullable=False)
|
||||
attempt: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
max_retry: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
timeout_s: Mapped[float] = mapped_column(Float, default=600.0, nullable=False)
|
||||
|
||||
# 非幂等步骤(默认)中断后不自动重跑 —— "自动重试把盘刷坏"是最容易砸招牌的失败模式
|
||||
idempotent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
danger: Mapped[str] = mapped_column(String(16), default="none")
|
||||
on_fail: Mapped[str] = mapped_column(String(16), default="retry")
|
||||
checkpoint_after: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# 租约:Agent 领了活就死了 → 租约到期 → 步骤回 PENDING 重新派发
|
||||
leased_by: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
lease_expires_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
|
||||
started_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
exit_code: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
error_class: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
result: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) # UnifiedResult
|
||||
log_uri: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
|
||||
|
||||
class RunEvent(Base):
|
||||
"""Append-only 事件流 —— 唯一真相。
|
||||
|
||||
seq 由控制平面单点分配(不按时间戳排序):主机断电、时钟漂移、
|
||||
带外与主机时钟不同步都会让时间戳乱序。Agent 事件与带外事件进同一条序列,
|
||||
双通道观测的交叉校验靠它。
|
||||
"""
|
||||
|
||||
__tablename__ = "run_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "seq", name="uq_event_run_seq"),
|
||||
# 网络抖动重发不产生重复事件(见 agent-protocol.md)
|
||||
UniqueConstraint("run_id", "idempotency_key", name="uq_event_idem"),
|
||||
Index("ix_event_run_seq", "run_id", "seq"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = pk("ev")
|
||||
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
source: Mapped[str] = mapped_column(String(16), default=EventSource.CONTROL_PLANE.value)
|
||||
kind: Mapped[str] = mapped_column(String(48), nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(16), default=Severity.INFO.value)
|
||||
|
||||
step_id: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
loop_index: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
payload: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
idempotency_key: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
|
||||
class RecoveryAction(Base):
|
||||
"""每一级恢复的留证记录。恢复前抓画面/温度,恢复后验证 DUT 重新枚举。"""
|
||||
|
||||
__tablename__ = "recovery_actions"
|
||||
|
||||
id: Mapped[str] = pk("rec")
|
||||
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
|
||||
step_id: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
loop_index: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
level: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
trigger: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
outcome: Mapped[Optional[str]] = mapped_column(String(16))
|
||||
|
||||
started_at: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
|
||||
ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
detail: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
evidence_uris: Mapped[List[Any]] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class ResourceLock(Base):
|
||||
"""DUT / 主机独占锁。PREFLIGHT 获取,终态释放。
|
||||
|
||||
唯一约束在 (resource_type, resource_id) —— 数据库来保证互斥,不靠应用层自觉。
|
||||
"""
|
||||
|
||||
__tablename__ = "resource_locks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("resource_type", "resource_id", name="uq_lock_resource"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = pk("lock")
|
||||
resource_type: Mapped[str] = mapped_column(String(16), nullable=False) # dut / test_host / oob
|
||||
resource_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
|
||||
acquired_at: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""工作流:SOP 的可执行形态。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import Boolean, Integer, JSON, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..enums import DangerLevel
|
||||
from .base import Base, TimestampMixin, pk
|
||||
|
||||
|
||||
class Workflow(Base, TimestampMixin):
|
||||
"""工作流定义。
|
||||
|
||||
spec 存原文,spec_hash 存规范化后的哈希。spec_hash 变了而 version 没变 →
|
||||
拒绝发布(见 workflow-spec.md)。理由:A/B 对比的结论必须能追溯到
|
||||
具体哪一版流程,否则"上周跑的和这周跑的是不是同一个流程"说不清。
|
||||
"""
|
||||
|
||||
__tablename__ = "workflows"
|
||||
__table_args__ = (UniqueConstraint("key", "version", name="uq_workflow_key_version"),)
|
||||
|
||||
id: Mapped[str] = pk("wf")
|
||||
key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
spec: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
spec_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
danger_level: Mapped[str] = mapped_column(String(16), default=DangerLevel.NONE.value)
|
||||
source_sop: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
approved_by: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
@@ -0,0 +1,5 @@
|
||||
"""危险动作的安全门禁。"""
|
||||
|
||||
from .gates import PreflightRequest, PreflightResult, run_preflight
|
||||
|
||||
__all__ = ["PreflightRequest", "PreflightResult", "run_preflight"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""安全默认拒绝的预检规则。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import PurePath
|
||||
from typing import Dict, List, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Check:
|
||||
key: str
|
||||
name: str
|
||||
passed: bool
|
||||
note: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreflightRequest:
|
||||
expected_serial: str
|
||||
discovered_serial: str
|
||||
allow_destructive: bool
|
||||
device_path: str
|
||||
system_device_paths: Sequence[str]
|
||||
firmware_model_allowed: bool
|
||||
host_online: bool
|
||||
oob_online: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreflightResult:
|
||||
passed: bool
|
||||
checks: List[Check]
|
||||
|
||||
def as_dict(self) -> Dict[str, object]:
|
||||
return {
|
||||
"passed": self.passed,
|
||||
"checks": [asdict(check) for check in self.checks],
|
||||
}
|
||||
|
||||
|
||||
def _same_device(left: str, right: str) -> bool:
|
||||
left_name = PurePath(left).name
|
||||
right_name = PurePath(right).name
|
||||
if left_name == right_name:
|
||||
return True
|
||||
# /dev/nvme0n1p2 属于 /dev/nvme0n1;拒绝把其任一分区当作测试盘。
|
||||
return left_name.startswith(right_name + "p") or right_name.startswith(left_name + "p")
|
||||
|
||||
|
||||
def run_preflight(request: PreflightRequest) -> PreflightResult:
|
||||
serial_ok = bool(request.expected_serial) and (
|
||||
request.expected_serial == request.discovered_serial
|
||||
)
|
||||
system_disk_safe = bool(request.device_path) and not any(
|
||||
_same_device(request.device_path, path) for path in request.system_device_paths
|
||||
)
|
||||
checks = [
|
||||
Check("serial", "DUT 序列号白名单绑定", serial_ok, request.discovered_serial or "未发现"),
|
||||
Check(
|
||||
"destructive",
|
||||
"破坏性写入授权",
|
||||
request.allow_destructive,
|
||||
"已审批" if request.allow_destructive else "DUT 未授权破坏性写入",
|
||||
),
|
||||
Check(
|
||||
"system_disk",
|
||||
"系统盘 / 分区保护",
|
||||
system_disk_safe,
|
||||
"启动盘已排除" if system_disk_safe else "目标命中系统盘或其分区",
|
||||
),
|
||||
Check(
|
||||
"firmware",
|
||||
"固件适配型号",
|
||||
request.firmware_model_allowed,
|
||||
"型号匹配" if request.firmware_model_allowed else "固件不适用于该型号",
|
||||
),
|
||||
Check(
|
||||
"host",
|
||||
"测试主机与 Agent",
|
||||
request.host_online,
|
||||
"在线" if request.host_online else "主机离线",
|
||||
),
|
||||
Check(
|
||||
"oob",
|
||||
"带外控制器",
|
||||
request.oob_online,
|
||||
"Power / Reset 可用" if request.oob_online else "带外通道离线",
|
||||
),
|
||||
]
|
||||
return PreflightResult(all(check.passed for check in checks), checks)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HTTP API 的 Pydantic 契约。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PreflightBody(BaseModel):
|
||||
dut_id: Optional[str] = None
|
||||
host_id: Optional[str] = None
|
||||
firmware_id: Optional[str] = None
|
||||
discovered_serial: Optional[str] = None
|
||||
|
||||
|
||||
class CreateRunBody(BaseModel):
|
||||
name: str = "固件 A/B 无人值守回归"
|
||||
workflow_id: Optional[str] = None
|
||||
dut_id: Optional[str] = None
|
||||
host_id: Optional[str] = None
|
||||
firmware_a_id: Optional[str] = None
|
||||
firmware_b_id: Optional[str] = None
|
||||
loops: int = Field(default=8, ge=1, le=500)
|
||||
inject_failure: bool = True
|
||||
execution_mode: Literal["simulated", "agent_dry_run"] = "simulated"
|
||||
created_by: str = "console-demo"
|
||||
|
||||
|
||||
class HumanActionBody(BaseModel):
|
||||
actor: str = "console-user"
|
||||
reason: str = "人工操作"
|
||||
|
||||
|
||||
class AgentRegisterBody(BaseModel):
|
||||
host_id: Optional[str] = None
|
||||
host_name: Optional[str] = None
|
||||
agent_version: str = Field(min_length=1, max_length=32)
|
||||
os_family: str = Field(default="linux", min_length=1, max_length=32)
|
||||
os_version: Optional[str] = Field(default=None, max_length=128)
|
||||
kernel: Optional[str] = Field(default=None, max_length=128)
|
||||
cpu: Optional[str] = Field(default=None, max_length=128)
|
||||
motherboard: Optional[str] = Field(default=None, max_length=128)
|
||||
bios_version: Optional[str] = Field(default=None, max_length=64)
|
||||
ip_address: Optional[str] = Field(default=None, max_length=64)
|
||||
toolchain: Dict[str, Any] = Field(default_factory=dict)
|
||||
labels: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentHeartbeatBody(BaseModel):
|
||||
healthy: bool = True
|
||||
agent_version: Optional[str] = Field(default=None, max_length=32)
|
||||
ip_address: Optional[str] = Field(default=None, max_length=64)
|
||||
toolchain: Dict[str, Any] = Field(default_factory=dict)
|
||||
device_probe: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentLeaseBody(BaseModel):
|
||||
wait_timeout_s: float = Field(default=0.0, ge=0.0, le=20.0)
|
||||
|
||||
|
||||
class AgentStepAttemptBody(BaseModel):
|
||||
attempt: int = Field(ge=1)
|
||||
|
||||
|
||||
class AgentStepEvent(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=2000)
|
||||
severity: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentStepEventsBody(AgentStepAttemptBody):
|
||||
events: List[AgentStepEvent] = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class AgentStepCompleteBody(AgentStepAttemptBody):
|
||||
status: Literal["SUCCEEDED", "FAILED"]
|
||||
exit_code: Optional[int] = None
|
||||
error_class: Optional[str] = Field(default=None, max_length=32)
|
||||
result: Dict[str, Any] = Field(default_factory=dict)
|
||||
log_uri: Optional[str] = Field(default=None, max_length=512)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""可重复执行的演示数据。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .enums import DutStatus, HostStatus, PowerState
|
||||
from .models import Dut, FirmwareArtifact, OobController, TestHost, Workflow, utcnow
|
||||
|
||||
|
||||
def _spec_hash(spec: dict) -> str:
|
||||
raw = json.dumps(spec, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def ensure_seed_data(session: AsyncSession) -> None:
|
||||
existing = await session.scalar(select(Workflow.id).limit(1))
|
||||
if existing:
|
||||
return
|
||||
|
||||
oob = OobController(
|
||||
name="OOB-05",
|
||||
kind="simulated",
|
||||
endpoint="sim://oob-05",
|
||||
capabilities={"power": True, "reset": True, "atx": True, "ac": True, "hdmi": True},
|
||||
status=HostStatus.ONLINE.value,
|
||||
power_state=PowerState.ON.value,
|
||||
last_seen_at=utcnow(),
|
||||
temperature_c=42.6,
|
||||
)
|
||||
session.add(oob)
|
||||
await session.flush()
|
||||
|
||||
host = TestHost(
|
||||
name="WS-05",
|
||||
os_family="linux",
|
||||
os_version="Ubuntu 22.04.4 LTS",
|
||||
kernel="6.8.0-40-generic",
|
||||
cpu="Intel Core i7-13700",
|
||||
motherboard="FlashOps SIM-X1",
|
||||
bios_version="F26b",
|
||||
agent_version="0.1.0-sim",
|
||||
ip_address="192.0.2.15",
|
||||
status=HostStatus.ONLINE.value,
|
||||
last_heartbeat_at=utcnow(),
|
||||
oob_controller_id=oob.id,
|
||||
toolchain={"fio": "3.36", "nvme-cli": "2.9.1"},
|
||||
labels={"system_device_paths": ["/dev/nvme0n1"]},
|
||||
)
|
||||
session.add(host)
|
||||
await session.flush()
|
||||
|
||||
dut = Dut(
|
||||
serial="S6XNNA0T609",
|
||||
model="NV-E1T92",
|
||||
vendor="Lab Sample",
|
||||
capacity_gb=1920,
|
||||
form_factor="U.2",
|
||||
current_firmware="3.2.1",
|
||||
bdf="0000:03:00.0",
|
||||
device_path="/dev/nvme1n1",
|
||||
test_host_id=host.id,
|
||||
status=DutStatus.IDLE.value,
|
||||
allow_destructive=True,
|
||||
health={"temperature_c": 43, "percentage_used": 12, "critical_warning": 0},
|
||||
labels={"purpose": "test-only"},
|
||||
)
|
||||
session.add(dut)
|
||||
|
||||
firmware_a = FirmwareArtifact(
|
||||
version="3.2.1",
|
||||
label="A / 基线",
|
||||
sha256="4bd1" + "0" * 56 + "08ce",
|
||||
signature="simulated-valid-signature",
|
||||
applicable_models=[dut.model],
|
||||
file_uri="sim://firmware/FW_3.2.1.bin",
|
||||
uploaded_by="seed",
|
||||
)
|
||||
firmware_b = FirmwareArtifact(
|
||||
version="3.3.0-rc3",
|
||||
label="B / 候选",
|
||||
sha256="9f2c" + "0" * 56 + "41aa",
|
||||
signature="simulated-valid-signature",
|
||||
applicable_models=[dut.model],
|
||||
file_uri="sim://firmware/FW_3.3.0-rc3.bin",
|
||||
uploaded_by="seed",
|
||||
)
|
||||
session.add_all([firmware_a, firmware_b])
|
||||
|
||||
spec = {
|
||||
"key": "fw-ab-regression",
|
||||
"name": "固件 A/B 电源状态回归",
|
||||
"version": 3,
|
||||
"danger_level": "high",
|
||||
"params": {"loops": {"type": "int", "default": 8, "min": 1, "max": 10000}},
|
||||
"steps": [
|
||||
{"key": "preflight", "type": "precheck", "adapter": "safety"},
|
||||
{
|
||||
"key": "flash-fw-b",
|
||||
"type": "command",
|
||||
"adapter": "nvme_cli",
|
||||
"template": "nvme_fw_download_commit",
|
||||
"danger": "high",
|
||||
"idempotent": False,
|
||||
"checkpoint": True,
|
||||
},
|
||||
{
|
||||
"key": "regression-loop",
|
||||
"type": "loop",
|
||||
"body": [
|
||||
{
|
||||
"key": "workload",
|
||||
"type": "command",
|
||||
"adapter": "fio",
|
||||
"idempotent": True,
|
||||
"retry": 1,
|
||||
},
|
||||
{"key": "enumeration-check", "type": "device_check", "adapter": "nvme_cli"},
|
||||
{"key": "integrity-check", "type": "command", "adapter": "shell"},
|
||||
],
|
||||
"checkpoint": True,
|
||||
},
|
||||
{"key": "report", "type": "report", "adapter": "builtin"},
|
||||
],
|
||||
}
|
||||
workflow = Workflow(
|
||||
key=spec["key"],
|
||||
name=spec["name"],
|
||||
version=spec["version"],
|
||||
spec=spec,
|
||||
spec_hash=_spec_hash(spec),
|
||||
danger_level="high",
|
||||
source_sop="内置模拟 SOP v1",
|
||||
published=True,
|
||||
approved_by="system-seed",
|
||||
)
|
||||
session.add(workflow)
|
||||
await session.flush()
|
||||
@@ -0,0 +1 @@
|
||||
"""应用服务。"""
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Agent heartbeat aging and Host status projection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..config import TimingPolicy, get_settings
|
||||
from ..db import session_scope
|
||||
from ..enums import HostStatus
|
||||
from ..models import TestHost, utcnow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def projected_agent_status(
|
||||
last_heartbeat_at: Optional[dt.datetime],
|
||||
current_status: str,
|
||||
*,
|
||||
now: Optional[dt.datetime] = None,
|
||||
timing: Optional[TimingPolicy] = None,
|
||||
) -> str:
|
||||
"""Return the status implied by heartbeat age.
|
||||
|
||||
A fresh explicit DEGRADED report remains degraded until the Agent sends a
|
||||
healthy heartbeat. Only Agents with an ``agent_id`` use this projection;
|
||||
seeded simulator Hosts retain their configured status.
|
||||
"""
|
||||
|
||||
if last_heartbeat_at is None:
|
||||
return HostStatus.OFFLINE.value
|
||||
policy = timing or get_settings().timing
|
||||
age_s = max(0.0, ((now or utcnow()) - last_heartbeat_at).total_seconds())
|
||||
if age_s >= policy.heartbeat_lost_after_s:
|
||||
return HostStatus.OFFLINE.value
|
||||
if age_s >= policy.heartbeat_degraded_after_s:
|
||||
return HostStatus.DEGRADED.value
|
||||
if current_status == HostStatus.DEGRADED.value:
|
||||
return HostStatus.DEGRADED.value
|
||||
return HostStatus.ONLINE.value
|
||||
|
||||
|
||||
def refresh_agent_host(host: TestHost, *, now: Optional[dt.datetime] = None) -> str:
|
||||
"""Refresh one registered Host in the caller's transaction."""
|
||||
|
||||
if not host.agent_id:
|
||||
return host.status
|
||||
host.status = projected_agent_status(
|
||||
host.last_heartbeat_at,
|
||||
host.status,
|
||||
now=now,
|
||||
)
|
||||
return host.status
|
||||
|
||||
|
||||
async def refresh_all_agent_hosts(*, now: Optional[dt.datetime] = None) -> int:
|
||||
"""Persist heartbeat-derived states for all registered Agents."""
|
||||
|
||||
changed = 0
|
||||
async with session_scope() as session:
|
||||
hosts = (
|
||||
await session.scalars(
|
||||
select(TestHost).where(TestHost.agent_id.is_not(None))
|
||||
)
|
||||
).all()
|
||||
for host in hosts:
|
||||
previous = host.status
|
||||
refresh_agent_host(host, now=now)
|
||||
if host.status != previous:
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
async def run_agent_watchdog() -> None:
|
||||
"""Continuously age registered Agent heartbeats."""
|
||||
|
||||
interval_s = max(1.0, get_settings().timing.heartbeat_interval_s)
|
||||
while True:
|
||||
try:
|
||||
await refresh_all_agent_hosts()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Agent heartbeat watchdog failed")
|
||||
await asyncio.sleep(interval_s)
|
||||
|
||||
|
||||
async def stop_agent_watchdog(task: "asyncio.Task[None]") -> None:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -0,0 +1,560 @@
|
||||
"""Run 创建、查询与内置模拟执行器。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import session_scope
|
||||
from ..enums import (
|
||||
EventKind,
|
||||
EventSource,
|
||||
HostStatus,
|
||||
RecoveryLevel,
|
||||
RecoveryOutcome,
|
||||
RecoveryTrigger,
|
||||
RunState,
|
||||
Severity,
|
||||
Verdict,
|
||||
)
|
||||
from ..events import append_event
|
||||
from ..evidence import build_evidence_bundle
|
||||
from ..engine.planner import materialize_run_steps
|
||||
from ..models import (
|
||||
Dut,
|
||||
FirmwareArtifact,
|
||||
OobController,
|
||||
RecoveryAction,
|
||||
ResourceLock,
|
||||
Run,
|
||||
RunEvent,
|
||||
TestHost,
|
||||
Workflow,
|
||||
iso_z,
|
||||
utcnow,
|
||||
)
|
||||
from ..safety import PreflightRequest, run_preflight
|
||||
from ..schemas import CreateRunBody, PreflightBody
|
||||
from .agent_status import refresh_agent_host
|
||||
|
||||
_tasks: Dict[str, asyncio.Task[None]] = {}
|
||||
_task_refs: Set[asyncio.Task[None]] = set()
|
||||
|
||||
|
||||
async def _first(session: AsyncSession, model: Any) -> Any:
|
||||
value = await session.scalar(select(model).limit(1))
|
||||
if value is None:
|
||||
raise LookupError("演示数据尚未初始化")
|
||||
return value
|
||||
|
||||
|
||||
async def resolve_assets(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
dut_id: Optional[str] = None,
|
||||
host_id: Optional[str] = None,
|
||||
firmware_id: Optional[str] = None,
|
||||
) -> tuple[Dut, TestHost, Optional[FirmwareArtifact], Optional[OobController]]:
|
||||
dut = await session.get(Dut, dut_id) if dut_id else await _first(session, Dut)
|
||||
host = await session.get(TestHost, host_id) if host_id else await _first(session, TestHost)
|
||||
firmware = (
|
||||
await session.get(FirmwareArtifact, firmware_id)
|
||||
if firmware_id
|
||||
else await _first(session, FirmwareArtifact)
|
||||
)
|
||||
if dut is None or host is None:
|
||||
raise LookupError("DUT 或测试主机不存在")
|
||||
oob = await session.get(OobController, host.oob_controller_id) if host.oob_controller_id else None
|
||||
return dut, host, firmware, oob
|
||||
|
||||
|
||||
async def preflight_for_body(session: AsyncSession, body: PreflightBody) -> Dict[str, object]:
|
||||
dut, host, firmware, oob = await resolve_assets(
|
||||
session,
|
||||
dut_id=body.dut_id,
|
||||
host_id=body.host_id,
|
||||
firmware_id=body.firmware_id,
|
||||
)
|
||||
refresh_agent_host(host)
|
||||
system_paths = host.labels.get("system_device_paths", []) if host.labels else []
|
||||
allowed = firmware is not None and (
|
||||
not firmware.applicable_models or dut.model in firmware.applicable_models
|
||||
)
|
||||
result = run_preflight(
|
||||
PreflightRequest(
|
||||
expected_serial=dut.serial,
|
||||
discovered_serial=body.discovered_serial or dut.serial,
|
||||
allow_destructive=dut.allow_destructive,
|
||||
device_path=dut.device_path or "",
|
||||
system_device_paths=system_paths,
|
||||
firmware_model_allowed=allowed,
|
||||
host_online=host.status == HostStatus.ONLINE.value,
|
||||
oob_online=oob is not None and oob.status == HostStatus.ONLINE.value,
|
||||
)
|
||||
)
|
||||
payload = result.as_dict()
|
||||
payload.update({"dut_id": dut.id, "host_id": host.id, "firmware_id": firmware.id if firmware else None})
|
||||
return payload
|
||||
|
||||
|
||||
async def create_run(session: AsyncSession, body: CreateRunBody) -> Run:
|
||||
workflow = (
|
||||
await session.get(Workflow, body.workflow_id)
|
||||
if body.workflow_id
|
||||
else await _first(session, Workflow)
|
||||
)
|
||||
dut, host, _, _ = await resolve_assets(
|
||||
session, dut_id=body.dut_id, host_id=body.host_id
|
||||
)
|
||||
firmwares = (
|
||||
await session.scalars(select(FirmwareArtifact).order_by(FirmwareArtifact.created_at))
|
||||
).all()
|
||||
if not firmwares:
|
||||
raise LookupError("固件数据尚未初始化")
|
||||
firmware_a = (
|
||||
await session.get(FirmwareArtifact, body.firmware_a_id)
|
||||
if body.firmware_a_id
|
||||
else firmwares[0]
|
||||
)
|
||||
firmware_b = (
|
||||
await session.get(FirmwareArtifact, body.firmware_b_id)
|
||||
if body.firmware_b_id
|
||||
else firmwares[-1]
|
||||
)
|
||||
assert workflow is not None and firmware_a is not None and firmware_b is not None
|
||||
env = {
|
||||
"host": host.name,
|
||||
"os": host.os_version,
|
||||
"kernel": host.kernel,
|
||||
"bios": host.bios_version,
|
||||
"agent": host.agent_version,
|
||||
"toolchain": host.toolchain,
|
||||
"dut_serial": dut.serial,
|
||||
"dut_model": dut.model,
|
||||
}
|
||||
env_hash = hashlib.sha256(
|
||||
json.dumps(env, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()
|
||||
run = Run(
|
||||
name=body.name,
|
||||
workflow_id=workflow.id,
|
||||
workflow_key=workflow.key,
|
||||
workflow_version=workflow.version,
|
||||
spec_hash=workflow.spec_hash,
|
||||
dut_id=dut.id,
|
||||
test_host_id=host.id,
|
||||
firmware_a_id=firmware_a.id,
|
||||
firmware_b_id=firmware_b.id,
|
||||
params={
|
||||
"inject_failure": body.inject_failure,
|
||||
"execution_mode": body.execution_mode,
|
||||
"loops": body.loops,
|
||||
"fw_a": firmware_a.id,
|
||||
"fw_b": firmware_b.id,
|
||||
"firmware_a_version": firmware_a.version,
|
||||
"firmware_b_version": firmware_b.version,
|
||||
},
|
||||
env_fingerprint=env,
|
||||
env_fingerprint_hash=env_hash,
|
||||
loop_target=body.loops,
|
||||
created_by=body.created_by,
|
||||
)
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_CREATED.value,
|
||||
"任务已创建,等待独占资源",
|
||||
payload={"loops": body.loops, "inject_failure": body.inject_failure},
|
||||
)
|
||||
await append_event(
|
||||
session, run, EventKind.RUN_QUEUED.value, "任务已进入调度队列"
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
def run_to_dict(run: Run) -> Dict[str, Any]:
|
||||
progress = round((run.loop_done / run.loop_target) * 100, 1) if run.loop_target else 0
|
||||
return {
|
||||
"id": run.id,
|
||||
"name": run.name,
|
||||
"state": run.state,
|
||||
"verdict": run.verdict,
|
||||
"workflow_key": run.workflow_key,
|
||||
"workflow_version": run.workflow_version,
|
||||
"execution_mode": run.params.get("execution_mode", "simulated"),
|
||||
"dut_id": run.dut_id,
|
||||
"test_host_id": run.test_host_id,
|
||||
"loop_target": run.loop_target,
|
||||
"loop_done": run.loop_done,
|
||||
"progress": progress,
|
||||
"checkpoint": run.checkpoint,
|
||||
"environment": run.env_fingerprint,
|
||||
"firmware_a_version": run.params.get("firmware_a_version"),
|
||||
"firmware_b_version": run.params.get("firmware_b_version"),
|
||||
"recovery_count": run.recovery_count,
|
||||
"unattended_completion": run.unattended_completion,
|
||||
"human_touches": run.human_touches,
|
||||
"created_at": iso_z(run.created_at),
|
||||
"started_at": iso_z(run.started_at),
|
||||
"ended_at": iso_z(run.ended_at),
|
||||
"freeze_reason": run.freeze_reason,
|
||||
}
|
||||
|
||||
|
||||
async def prepare_agent_run(session: AsyncSession, run: Run) -> None:
|
||||
"""Run safety gates, lock resources and materialize an Agent dry-run."""
|
||||
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_PREFLIGHT_STARTED.value,
|
||||
"已锁定执行计划,开始 Agent 任务安全预检",
|
||||
target_state=RunState.PREFLIGHT.value,
|
||||
)
|
||||
preflight = await preflight_for_body(
|
||||
session,
|
||||
PreflightBody(
|
||||
dut_id=run.dut_id,
|
||||
host_id=run.test_host_id,
|
||||
firmware_id=run.firmware_b_id,
|
||||
),
|
||||
)
|
||||
if not preflight["passed"]:
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_REJECTED.value,
|
||||
"安全预检未通过",
|
||||
severity=Severity.CRITICAL.value,
|
||||
payload=preflight,
|
||||
target_state=RunState.REJECTED.value,
|
||||
projection={"ended_at": utcnow()},
|
||||
)
|
||||
return
|
||||
|
||||
existing_lock = await session.scalar(
|
||||
select(ResourceLock.id).where(
|
||||
ResourceLock.resource_type.in_(["dut", "test_host"]),
|
||||
ResourceLock.resource_id.in_([run.dut_id or "", run.test_host_id or ""]),
|
||||
)
|
||||
)
|
||||
if existing_lock:
|
||||
raise LookupError("DUT 或测试主机已被其他任务锁定")
|
||||
|
||||
workflow = await session.get(Workflow, run.workflow_id)
|
||||
if workflow is None:
|
||||
raise LookupError("Run 关联工作流不存在")
|
||||
steps = await materialize_run_steps(session, run, workflow)
|
||||
session.add_all(
|
||||
[
|
||||
ResourceLock(
|
||||
resource_type="dut",
|
||||
resource_id=run.dut_id or "",
|
||||
run_id=run.id,
|
||||
acquired_at=utcnow(),
|
||||
),
|
||||
ResourceLock(
|
||||
resource_type="test_host",
|
||||
resource_id=run.test_host_id or "",
|
||||
run_id=run.id,
|
||||
acquired_at=utcnow(),
|
||||
),
|
||||
]
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.SAFETY_APPROVED.value,
|
||||
"6/6 安全门禁通过",
|
||||
payload=preflight,
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.LOCK_ACQUIRED.value,
|
||||
"DUT 与测试主机独占锁已获取",
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_STARTED.value,
|
||||
"独立 Host Agent dry-run 已就绪,共 {0} 个步骤".format(len(steps)),
|
||||
payload={"step_count": len(steps), "execution_mode": "agent_dry_run"},
|
||||
target_state=RunState.RUNNING.value,
|
||||
projection={"started_at": utcnow()},
|
||||
)
|
||||
|
||||
|
||||
def event_to_dict(event: RunEvent) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": event.id,
|
||||
"seq": event.seq,
|
||||
"ts": iso_z(event.ts),
|
||||
"source": event.source,
|
||||
"kind": event.kind,
|
||||
"severity": event.severity,
|
||||
"message": event.message,
|
||||
"payload": event.payload,
|
||||
}
|
||||
|
||||
|
||||
async def _sleep(seconds: float) -> None:
|
||||
await asyncio.sleep(max(0.01, seconds * get_settings().time_scale))
|
||||
|
||||
|
||||
async def _load_run(session: AsyncSession, run_id: str) -> Run:
|
||||
run = await session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise LookupError("Run 不存在: {0}".format(run_id))
|
||||
return run
|
||||
|
||||
|
||||
async def _run_recovery(run_id: str, loop_index: int) -> None:
|
||||
levels = [
|
||||
(RecoveryLevel.AGENT_SOFT, RecoveryOutcome.FAILED, "Agent 软恢复无响应"),
|
||||
(RecoveryLevel.OS_REBOOT, RecoveryOutcome.FAILED, "OS 重启通道超时"),
|
||||
(RecoveryLevel.OOB_RESET, RecoveryOutcome.RECOVERED, "带外 Reset 后主机与 DUT 已恢复"),
|
||||
]
|
||||
for level, outcome, message in levels:
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
action = RecoveryAction(
|
||||
run_id=run.id,
|
||||
loop_index=loop_index,
|
||||
level=level.value,
|
||||
trigger=RecoveryTrigger.HEARTBEAT_LOST.value,
|
||||
outcome=outcome.value,
|
||||
started_at=utcnow(),
|
||||
ended_at=utcnow(),
|
||||
detail={"simulated": True, "message": message},
|
||||
evidence_uris=["sim://oob/frame-{0}".format(loop_index)],
|
||||
)
|
||||
session.add(action)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RECOVERY_STARTED.value,
|
||||
"{0} 已执行".format(level.value),
|
||||
severity=Severity.WARNING.value,
|
||||
payload={"level": level.value, "outcome": outcome.value},
|
||||
projection={"recovery_count": run.recovery_count + 1},
|
||||
)
|
||||
if outcome == RecoveryOutcome.RECOVERED:
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RECOVERY_SUCCEEDED.value,
|
||||
message,
|
||||
source=EventSource.OOB.value,
|
||||
payload={"level": level.value},
|
||||
target_state=RunState.RUNNING.value,
|
||||
)
|
||||
else:
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RECOVERY_ESCALATED.value,
|
||||
message + ",升级恢复阶梯",
|
||||
severity=Severity.ERROR.value,
|
||||
payload={"level": level.value},
|
||||
)
|
||||
await _sleep(0.28)
|
||||
|
||||
|
||||
async def simulate_run(run_id: str) -> None:
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_PREFLIGHT_STARTED.value,
|
||||
"已锁定工位,开始安全预检",
|
||||
target_state=RunState.PREFLIGHT.value,
|
||||
)
|
||||
await _sleep(0.25)
|
||||
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
preflight = await preflight_for_body(
|
||||
session,
|
||||
PreflightBody(
|
||||
dut_id=run.dut_id,
|
||||
host_id=run.test_host_id,
|
||||
firmware_id=run.firmware_b_id,
|
||||
),
|
||||
)
|
||||
if not preflight["passed"]:
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_REJECTED.value,
|
||||
"安全预检未通过",
|
||||
severity=Severity.CRITICAL.value,
|
||||
payload=preflight,
|
||||
target_state=RunState.REJECTED.value,
|
||||
projection={"ended_at": utcnow()},
|
||||
)
|
||||
return
|
||||
session.add_all(
|
||||
[
|
||||
ResourceLock(
|
||||
resource_type="dut",
|
||||
resource_id=run.dut_id or "",
|
||||
run_id=run.id,
|
||||
acquired_at=utcnow(),
|
||||
),
|
||||
ResourceLock(
|
||||
resource_type="test_host",
|
||||
resource_id=run.test_host_id or "",
|
||||
run_id=run.id,
|
||||
acquired_at=utcnow(),
|
||||
),
|
||||
]
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.SAFETY_APPROVED.value,
|
||||
"6/6 安全门禁通过",
|
||||
payload=preflight,
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.LOCK_ACQUIRED.value,
|
||||
"DUT 与测试主机独占锁已获取",
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_STARTED.value,
|
||||
"模拟 Agent 已接管执行",
|
||||
target_state=RunState.RUNNING.value,
|
||||
projection={"started_at": utcnow()},
|
||||
)
|
||||
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
target = run.loop_target
|
||||
inject_failure = bool(run.params.get("inject_failure", False))
|
||||
failure_loop = min(3, target) if inject_failure and target >= 2 else -1
|
||||
|
||||
for loop_index in range(1, target + 1):
|
||||
while True:
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
if run.state != RunState.PAUSED.value:
|
||||
break
|
||||
await _sleep(0.2)
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
if run.state in {
|
||||
RunState.ABORTED.value,
|
||||
RunState.FROZEN.value,
|
||||
RunState.REJECTED.value,
|
||||
}:
|
||||
return
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.LOOP_STARTED.value,
|
||||
"循环 {0}/{1} 开始".format(loop_index, target),
|
||||
payload={"loop_index": loop_index},
|
||||
)
|
||||
await _sleep(0.22)
|
||||
|
||||
if loop_index == failure_loop:
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.HEARTBEAT_LOST.value,
|
||||
"Agent 心跳丢失;OOB 仍在线,启动自动恢复",
|
||||
source=EventSource.OOB.value,
|
||||
severity=Severity.ERROR.value,
|
||||
payload={"loop_index": loop_index, "oob_online": True},
|
||||
target_state=RunState.RECOVERING.value,
|
||||
)
|
||||
await _sleep(0.25)
|
||||
await _run_recovery(run_id, loop_index)
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.CHECKPOINT_RESUMED.value,
|
||||
"主机指纹一致,从循环检查点续跑",
|
||||
payload={"loop_index": loop_index},
|
||||
)
|
||||
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
checkpoint = {
|
||||
"loop_index": loop_index,
|
||||
"next_step_key": "regression-loop",
|
||||
"dut_fw": "B",
|
||||
}
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.LOOP_COMPLETED.value,
|
||||
"循环 {0}/{1} 通过,检查点已写入".format(loop_index, target),
|
||||
payload={"loop_index": loop_index},
|
||||
projection={"loop_done": loop_index, "checkpoint": checkpoint},
|
||||
)
|
||||
await _sleep(0.14)
|
||||
|
||||
async with session_scope() as session:
|
||||
run = await _load_run(session, run_id)
|
||||
bundle = await build_evidence_bundle(session, run)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.EVIDENCE_BUNDLED.value,
|
||||
"Evidence Bundle 已生成,完整率 100%",
|
||||
payload={"bundle_id": bundle.id, "uri": bundle.uri, "completeness": 1.0},
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.LOCK_RELEASED.value,
|
||||
"DUT 与测试主机独占锁已释放",
|
||||
)
|
||||
await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id))
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_COMPLETED.value,
|
||||
"全部循环完成,结论 PASS",
|
||||
target_state=RunState.COMPLETED.value,
|
||||
projection={"verdict": Verdict.PASS.value, "ended_at": utcnow()},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
finally:
|
||||
_tasks.pop(run_id, None)
|
||||
|
||||
|
||||
def start_simulation(run_id: str) -> None:
|
||||
existing = _tasks.get(run_id)
|
||||
if existing and not existing.done():
|
||||
return
|
||||
task = asyncio.create_task(simulate_run(run_id))
|
||||
_tasks[run_id] = task
|
||||
_task_refs.add(task)
|
||||
task.add_done_callback(_task_refs.discard)
|
||||
|
||||
|
||||
def cancel_simulation(run_id: str) -> None:
|
||||
task = _tasks.pop(run_id, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Reliable pull-mode Step lease lifecycle for Host Agents."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..enums import (
|
||||
EventKind,
|
||||
EventSource,
|
||||
OnFail,
|
||||
RunState,
|
||||
Severity,
|
||||
StepState,
|
||||
Verdict,
|
||||
)
|
||||
from ..events import append_event
|
||||
from ..evidence import build_evidence_bundle
|
||||
from ..models import ResourceLock, Run, RunEvent, RunStep, TestHost, iso_z, utcnow
|
||||
from ..schemas import AgentStepCompleteBody, AgentStepEvent
|
||||
|
||||
_ACTIVE_STEP_STATES = {
|
||||
StepState.PENDING.value,
|
||||
StepState.DISPATCHED.value,
|
||||
StepState.RUNNING.value,
|
||||
}
|
||||
|
||||
|
||||
class StepLeaseConflict(ValueError):
|
||||
"""The Agent request does not match the current lease generation/state."""
|
||||
|
||||
|
||||
def step_status(step: RunStep, *, replayed: bool = False) -> Dict[str, Any]:
|
||||
return {
|
||||
"step_id": step.id,
|
||||
"run_id": step.run_id,
|
||||
"state": step.state,
|
||||
"attempt": step.attempt,
|
||||
"lease_expires_at": iso_z(step.lease_expires_at),
|
||||
"replayed": replayed,
|
||||
}
|
||||
|
||||
|
||||
def step_lease(step: RunStep, run: Run) -> Dict[str, Any]:
|
||||
return {
|
||||
"step_id": step.id,
|
||||
"run_id": step.run_id,
|
||||
"seq": step.seq,
|
||||
"loop_index": step.loop_index,
|
||||
"step_key": step.step_key,
|
||||
"step_type": step.step_type,
|
||||
"adapter": step.adapter,
|
||||
"template": step.template,
|
||||
"params": step.params,
|
||||
"danger": step.danger,
|
||||
"idempotent": step.idempotent,
|
||||
"timeout_s": step.timeout_s,
|
||||
"attempt": step.attempt,
|
||||
"lease_expires_at": iso_z(step.lease_expires_at),
|
||||
# This slice exercises the real process/network protocol but never executes
|
||||
# a real hardware command. A future signed adapter registry will remove it.
|
||||
"dry_run": run.params.get("execution_mode") == "agent_dry_run",
|
||||
}
|
||||
|
||||
|
||||
async def _locked_run(session: AsyncSession, run_id: str) -> Run:
|
||||
run = await session.scalar(
|
||||
select(Run).where(Run.id == run_id).with_for_update()
|
||||
)
|
||||
if run is None:
|
||||
raise StepLeaseConflict("Run 不存在")
|
||||
return run
|
||||
|
||||
|
||||
async def _owned_step(
|
||||
session: AsyncSession, host: TestHost, step_id: str
|
||||
) -> tuple[RunStep, Run]:
|
||||
step = await session.scalar(
|
||||
select(RunStep)
|
||||
.join(Run, Run.id == RunStep.run_id)
|
||||
.where(RunStep.id == step_id, Run.test_host_id == host.id)
|
||||
.with_for_update()
|
||||
)
|
||||
if step is None:
|
||||
raise StepLeaseConflict("步骤不存在或不属于当前 Agent")
|
||||
run = await _locked_run(session, step.run_id)
|
||||
return step, run
|
||||
|
||||
|
||||
async def _replayed(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
run_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
operation: str,
|
||||
) -> bool:
|
||||
event = await session.scalar(
|
||||
select(RunEvent).where(
|
||||
RunEvent.run_id == run_id,
|
||||
RunEvent.idempotency_key == idempotency_key,
|
||||
)
|
||||
)
|
||||
if event is None:
|
||||
return False
|
||||
if event.step_id != step_id or event.payload.get("_operation") != operation:
|
||||
raise StepLeaseConflict("Idempotency-Key 已用于其他步骤或操作")
|
||||
return True
|
||||
|
||||
|
||||
def _assert_current(step: RunStep, agent_id: str, attempt: int) -> None:
|
||||
now = utcnow()
|
||||
if step.leased_by != agent_id or step.attempt != attempt:
|
||||
raise StepLeaseConflict("租约代次不匹配,步骤可能已被重新派发")
|
||||
if step.lease_expires_at is None or step.lease_expires_at <= now:
|
||||
raise StepLeaseConflict("步骤租约已过期")
|
||||
if (
|
||||
step.state == StepState.RUNNING.value
|
||||
and step.started_at is not None
|
||||
and step.started_at + _dt.timedelta(seconds=step.timeout_s) <= now
|
||||
):
|
||||
raise StepLeaseConflict("步骤执行已超过 timeout_s")
|
||||
|
||||
|
||||
async def _freeze_run(
|
||||
session: AsyncSession, run: Run, step: RunStep, reason: str
|
||||
) -> None:
|
||||
if run.state not in {RunState.RUNNING.value, RunState.RECOVERING.value}:
|
||||
return
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_FROZEN.value,
|
||||
reason,
|
||||
severity=Severity.CRITICAL.value,
|
||||
target_state=RunState.FROZEN.value,
|
||||
projection={
|
||||
"verdict": Verdict.INCONCLUSIVE.value,
|
||||
"freeze_reason": reason,
|
||||
"ended_at": utcnow(),
|
||||
},
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id))
|
||||
|
||||
|
||||
async def reclaim_expired_leases(session: AsyncSession, host: TestHost) -> int:
|
||||
"""Requeue safe work and freeze ambiguous non-idempotent work."""
|
||||
|
||||
now = utcnow()
|
||||
expired = (
|
||||
await session.scalars(
|
||||
select(RunStep)
|
||||
.join(Run, Run.id == RunStep.run_id)
|
||||
.where(
|
||||
Run.test_host_id == host.id,
|
||||
Run.state.in_([RunState.RUNNING.value, RunState.RECOVERING.value]),
|
||||
RunStep.state.in_([StepState.DISPATCHED.value, StepState.RUNNING.value]),
|
||||
RunStep.lease_expires_at.is_not(None),
|
||||
RunStep.lease_expires_at <= now,
|
||||
)
|
||||
.order_by(RunStep.run_id, RunStep.seq)
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
|
||||
for step in expired:
|
||||
run = await _locked_run(session, step.run_id)
|
||||
previous = step.state
|
||||
step.leased_by = None
|
||||
step.lease_expires_at = None
|
||||
|
||||
if previous == StepState.DISPATCHED.value:
|
||||
step.state = StepState.PENDING.value
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_LEASE_EXPIRED.value,
|
||||
"Agent 未 ACK,步骤租约已回收",
|
||||
severity=Severity.WARNING.value,
|
||||
payload={"attempt": step.attempt, "previous_state": previous},
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
continue
|
||||
|
||||
can_retry = step.idempotent and step.attempt <= step.max_retry
|
||||
if can_retry:
|
||||
step.state = StepState.PENDING.value
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_RETRYING.value,
|
||||
"Agent 执行中失联,幂等步骤已安全回收等待重试",
|
||||
severity=Severity.WARNING.value,
|
||||
payload={"attempt": step.attempt, "max_retry": step.max_retry},
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
continue
|
||||
|
||||
step.state = StepState.TIMED_OUT.value
|
||||
step.ended_at = now
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_TIMED_OUT.value,
|
||||
"执行租约到期,步骤结果不确定",
|
||||
severity=Severity.ERROR.value,
|
||||
payload={
|
||||
"attempt": step.attempt,
|
||||
"idempotent": step.idempotent,
|
||||
"retry_exhausted": step.idempotent,
|
||||
},
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
await _freeze_run(
|
||||
session,
|
||||
run,
|
||||
step,
|
||||
"步骤 {0} 执行结果不确定,已冻结现场禁止盲目重跑".format(step.step_key),
|
||||
)
|
||||
|
||||
return len(expired)
|
||||
|
||||
|
||||
async def try_lease_next_step(
|
||||
session: AsyncSession, host: TestHost
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
await reclaim_expired_leases(session, host)
|
||||
runs = (
|
||||
await session.scalars(
|
||||
select(Run)
|
||||
.where(
|
||||
Run.test_host_id == host.id,
|
||||
Run.state == RunState.RUNNING.value,
|
||||
)
|
||||
.order_by(Run.created_at, Run.id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
).all()
|
||||
|
||||
for run in runs:
|
||||
steps = (
|
||||
await session.scalars(
|
||||
select(RunStep)
|
||||
.where(RunStep.run_id == run.id, RunStep.state.in_(_ACTIVE_STEP_STATES))
|
||||
.order_by(RunStep.seq)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
).all()
|
||||
if not steps:
|
||||
continue
|
||||
step = steps[0]
|
||||
if step.state != StepState.PENDING.value:
|
||||
continue
|
||||
|
||||
step.state = StepState.DISPATCHED.value
|
||||
step.attempt += 1
|
||||
step.leased_by = host.agent_id
|
||||
step.lease_expires_at = utcnow() + _dt.timedelta(
|
||||
seconds=get_settings().timing.lease_ttl_s
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_DISPATCHED.value,
|
||||
"步骤已租给 Host Agent,等待 ACK",
|
||||
payload={"agent_id": host.agent_id, "attempt": step.attempt},
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
return step_lease(step, run)
|
||||
return None
|
||||
|
||||
|
||||
async def acknowledge_step(
|
||||
session: AsyncSession,
|
||||
host: TestHost,
|
||||
step_id: str,
|
||||
attempt: int,
|
||||
idempotency_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
step, run = await _owned_step(session, host, step_id)
|
||||
if await _replayed(
|
||||
session,
|
||||
run_id=run.id,
|
||||
step_id=step.id,
|
||||
idempotency_key=idempotency_key,
|
||||
operation="ack",
|
||||
):
|
||||
return step_status(step, replayed=True)
|
||||
if step.state != StepState.DISPATCHED.value:
|
||||
raise StepLeaseConflict("只有 DISPATCHED 步骤可以 ACK")
|
||||
_assert_current(step, host.agent_id or "", attempt)
|
||||
|
||||
now = utcnow()
|
||||
step.state = StepState.RUNNING.value
|
||||
step.started_at = now
|
||||
step.lease_expires_at = min(
|
||||
now + _dt.timedelta(seconds=get_settings().timing.lease_ttl_s),
|
||||
now + _dt.timedelta(seconds=step.timeout_s),
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_STARTED.value,
|
||||
"Host Agent 已确认接手步骤",
|
||||
source=EventSource.AGENT.value,
|
||||
payload={"_operation": "ack", "attempt": attempt},
|
||||
idempotency_key=idempotency_key,
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
return step_status(step)
|
||||
|
||||
async def renew_step(
|
||||
session: AsyncSession,
|
||||
host: TestHost,
|
||||
step_id: str,
|
||||
attempt: int,
|
||||
idempotency_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
step, run = await _owned_step(session, host, step_id)
|
||||
if await _replayed(
|
||||
session,
|
||||
run_id=run.id,
|
||||
step_id=step.id,
|
||||
idempotency_key=idempotency_key,
|
||||
operation="renew",
|
||||
):
|
||||
return step_status(step, replayed=True)
|
||||
if step.state != StepState.RUNNING.value:
|
||||
raise StepLeaseConflict("只有 RUNNING 步骤可以续租")
|
||||
_assert_current(step, host.agent_id or "", attempt)
|
||||
assert step.started_at is not None
|
||||
now = utcnow()
|
||||
step.lease_expires_at = min(
|
||||
now + _dt.timedelta(seconds=get_settings().timing.lease_ttl_s),
|
||||
step.started_at + _dt.timedelta(seconds=step.timeout_s),
|
||||
)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_LEASE_RENEWED.value,
|
||||
"Host Agent 已续租执行步骤",
|
||||
source=EventSource.AGENT.value,
|
||||
severity=Severity.DEBUG.value,
|
||||
payload={"_operation": "renew", "attempt": attempt},
|
||||
idempotency_key=idempotency_key,
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
return step_status(step)
|
||||
|
||||
|
||||
async def record_step_events(
|
||||
session: AsyncSession,
|
||||
host: TestHost,
|
||||
step_id: str,
|
||||
attempt: int,
|
||||
events: List[AgentStepEvent],
|
||||
idempotency_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
step, run = await _owned_step(session, host, step_id)
|
||||
first_key = "{0}:0".format(idempotency_key)
|
||||
if await _replayed(
|
||||
session,
|
||||
run_id=run.id,
|
||||
step_id=step.id,
|
||||
idempotency_key=first_key,
|
||||
operation="events",
|
||||
):
|
||||
return {**step_status(step, replayed=True), "accepted": len(events)}
|
||||
if step.state != StepState.RUNNING.value:
|
||||
raise StepLeaseConflict("只有 RUNNING 步骤可以上报事件")
|
||||
_assert_current(step, host.agent_id or "", attempt)
|
||||
|
||||
for index, item in enumerate(events):
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_OUTPUT.value,
|
||||
item.message,
|
||||
source=EventSource.AGENT.value,
|
||||
severity=item.severity,
|
||||
payload={"_operation": "events", "attempt": attempt, **item.payload},
|
||||
idempotency_key="{0}:{1}".format(idempotency_key, index),
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
return {**step_status(step), "accepted": len(events)}
|
||||
|
||||
|
||||
async def _finish_run(session: AsyncSession, run: Run, verdict: str) -> None:
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.LOCK_RELEASED.value,
|
||||
"DUT 与测试主机独占锁已释放",
|
||||
)
|
||||
await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id))
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.RUN_COMPLETED.value,
|
||||
"独立 Host Agent 已完成全部步骤,结论 {0}".format(verdict),
|
||||
target_state=RunState.COMPLETED.value,
|
||||
projection={"verdict": verdict, "ended_at": utcnow()},
|
||||
)
|
||||
bundle = await build_evidence_bundle(session, run)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.EVIDENCE_BUNDLED.value,
|
||||
"Evidence Bundle 已生成,完整率 100%",
|
||||
payload={"bundle_id": bundle.id, "uri": bundle.uri, "completeness": 1.0},
|
||||
)
|
||||
|
||||
|
||||
async def complete_step(
|
||||
session: AsyncSession,
|
||||
host: TestHost,
|
||||
step_id: str,
|
||||
body: AgentStepCompleteBody,
|
||||
idempotency_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
step, run = await _owned_step(session, host, step_id)
|
||||
if await _replayed(
|
||||
session,
|
||||
run_id=run.id,
|
||||
step_id=step.id,
|
||||
idempotency_key=idempotency_key,
|
||||
operation="complete",
|
||||
):
|
||||
return step_status(step, replayed=True)
|
||||
if step.state != StepState.RUNNING.value:
|
||||
raise StepLeaseConflict("只有 RUNNING 步骤可以完成")
|
||||
_assert_current(step, host.agent_id or "", body.attempt)
|
||||
|
||||
step.exit_code = body.exit_code
|
||||
step.error_class = body.error_class
|
||||
step.result = body.result
|
||||
step.log_uri = body.log_uri
|
||||
step.ended_at = utcnow()
|
||||
step.leased_by = None
|
||||
step.lease_expires_at = None
|
||||
|
||||
succeeded = body.status == StepState.SUCCEEDED.value
|
||||
step.state = StepState.SUCCEEDED.value if succeeded else StepState.FAILED.value
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_SUCCEEDED.value if succeeded else EventKind.STEP_FAILED.value,
|
||||
"步骤执行成功" if succeeded else "步骤执行失败",
|
||||
source=EventSource.AGENT.value,
|
||||
severity=Severity.INFO.value if succeeded else Severity.ERROR.value,
|
||||
payload={
|
||||
"_operation": "complete",
|
||||
"attempt": body.attempt,
|
||||
"exit_code": body.exit_code,
|
||||
"error_class": body.error_class,
|
||||
"result": body.result,
|
||||
"log_uri": body.log_uri,
|
||||
},
|
||||
idempotency_key=idempotency_key,
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
|
||||
if not succeeded:
|
||||
if (
|
||||
step.on_fail == OnFail.RETRY.value
|
||||
and step.idempotent
|
||||
and step.attempt <= step.max_retry
|
||||
):
|
||||
step.state = StepState.PENDING.value
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.STEP_RETRYING.value,
|
||||
"幂等步骤失败,已进入受限重试队列",
|
||||
severity=Severity.WARNING.value,
|
||||
payload={"attempt": step.attempt, "max_retry": step.max_retry},
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
return step_status(step)
|
||||
if step.on_fail in {OnFail.FREEZE.value, OnFail.RECOVER.value} or not step.idempotent:
|
||||
await _freeze_run(
|
||||
session,
|
||||
run,
|
||||
step,
|
||||
"步骤 {0} 失败且不可安全自动重跑,已冻结现场".format(step.step_key),
|
||||
)
|
||||
return step_status(step)
|
||||
if step.on_fail == OnFail.FAIL_RUN.value:
|
||||
await _finish_run(session, run, Verdict.FAIL.value)
|
||||
return step_status(step)
|
||||
|
||||
remaining = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(RunStep)
|
||||
.where(RunStep.run_id == run.id, RunStep.state.in_(_ACTIVE_STEP_STATES))
|
||||
)
|
||||
|
||||
if succeeded and step.checkpoint_after:
|
||||
next_step = await session.scalar(
|
||||
select(RunStep)
|
||||
.where(RunStep.run_id == run.id, RunStep.seq > step.seq)
|
||||
.order_by(RunStep.seq)
|
||||
.limit(1)
|
||||
)
|
||||
checkpoint = {
|
||||
"loop_index": step.loop_index,
|
||||
"completed_step_key": step.step_key,
|
||||
"next_step_key": next_step.step_key if next_step else None,
|
||||
}
|
||||
loop_done = max(run.loop_done, step.loop_index or run.loop_done)
|
||||
await append_event(
|
||||
session,
|
||||
run,
|
||||
EventKind.CHECKPOINT_SAVED.value,
|
||||
"安全检查点已保存",
|
||||
payload=checkpoint,
|
||||
projection={"checkpoint": checkpoint, "loop_done": loop_done},
|
||||
step_id=step.id,
|
||||
loop_index=step.loop_index,
|
||||
)
|
||||
|
||||
if not remaining:
|
||||
failed = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(RunStep)
|
||||
.where(RunStep.run_id == run.id, RunStep.state == StepState.FAILED.value)
|
||||
)
|
||||
await _finish_run(
|
||||
session,
|
||||
run,
|
||||
Verdict.FAIL.value if failed else Verdict.PASS.value,
|
||||
)
|
||||
return step_status(step)
|
||||
@@ -0,0 +1,19 @@
|
||||
# 控制平面依赖。刻意保持短——每加一个依赖,客户内网私有化部署就多一分摩擦。
|
||||
fastapi>=0.115,<1.0
|
||||
uvicorn[standard]>=0.30
|
||||
sqlalchemy>=2.0.30
|
||||
greenlet>=3.0 # SQLAlchemy 异步会话的协程桥接
|
||||
aiosqlite>=0.20 # 开发态数据库
|
||||
pydantic>=2.9,<3.0
|
||||
pyyaml>=6.0 # 工作流定义
|
||||
httpx>=0.27 # 测试客户端 + Agent 侧复用
|
||||
python-dotenv>=1.0
|
||||
|
||||
# 生产(docker-compose)才需要,本地开发可不装:
|
||||
# asyncpg>=0.29
|
||||
# redis>=5.0
|
||||
# boto3>=1.34
|
||||
|
||||
# 开发/测试
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
from flashops_control.api.agents import _check_enrollment_token
|
||||
from flashops_control.config import reset_settings
|
||||
|
||||
|
||||
def test_production_agent_enrollment_is_fail_closed(monkeypatch):
|
||||
monkeypatch.setenv("FLASHOPS_ENV", "production")
|
||||
monkeypatch.delenv("FLASHOPS_AGENT_ENROLLMENT_TOKEN", raising=False)
|
||||
reset_settings()
|
||||
with pytest.raises(HTTPException) as missing:
|
||||
_check_enrollment_token(None)
|
||||
assert missing.value.status_code == 503
|
||||
|
||||
monkeypatch.setenv("FLASHOPS_AGENT_ENROLLMENT_TOKEN", "enroll-secret")
|
||||
reset_settings()
|
||||
with pytest.raises(HTTPException) as invalid:
|
||||
_check_enrollment_token("wrong")
|
||||
assert invalid.value.status_code == 401
|
||||
_check_enrollment_token("enroll-secret")
|
||||
|
||||
# Leave the global cache empty so later tests read the restored environment.
|
||||
reset_settings()
|
||||
@@ -0,0 +1,53 @@
|
||||
import datetime as dt
|
||||
|
||||
from flashops_control.config import TimingPolicy
|
||||
from flashops_control.enums import HostStatus
|
||||
from flashops_control.services.agent_status import projected_agent_status
|
||||
|
||||
|
||||
NOW = dt.datetime(2026, 7, 27, 12, 0, 0)
|
||||
TIMING = TimingPolicy(
|
||||
heartbeat_interval_s=5,
|
||||
heartbeat_degraded_after_s=15,
|
||||
heartbeat_lost_after_s=30,
|
||||
)
|
||||
|
||||
|
||||
def test_fresh_agent_heartbeat_is_online():
|
||||
status = projected_agent_status(
|
||||
NOW - dt.timedelta(seconds=14),
|
||||
HostStatus.ONLINE.value,
|
||||
now=NOW,
|
||||
timing=TIMING,
|
||||
)
|
||||
assert status == HostStatus.ONLINE.value
|
||||
|
||||
|
||||
def test_late_agent_heartbeat_is_degraded():
|
||||
status = projected_agent_status(
|
||||
NOW - dt.timedelta(seconds=15),
|
||||
HostStatus.ONLINE.value,
|
||||
now=NOW,
|
||||
timing=TIMING,
|
||||
)
|
||||
assert status == HostStatus.DEGRADED.value
|
||||
|
||||
|
||||
def test_lost_agent_heartbeat_is_offline():
|
||||
status = projected_agent_status(
|
||||
NOW - dt.timedelta(seconds=30),
|
||||
HostStatus.DEGRADED.value,
|
||||
now=NOW,
|
||||
timing=TIMING,
|
||||
)
|
||||
assert status == HostStatus.OFFLINE.value
|
||||
|
||||
|
||||
def test_fresh_unhealthy_agent_stays_degraded_until_healthy_report():
|
||||
status = projected_agent_status(
|
||||
NOW,
|
||||
HostStatus.DEGRADED.value,
|
||||
now=NOW,
|
||||
timing=TIMING,
|
||||
)
|
||||
assert status == HostStatus.DEGRADED.value
|
||||
@@ -0,0 +1,413 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
TEST_DB = Path("/tmp/flashops-e2e-{0}.db".format(os.getpid()))
|
||||
TEST_OBJECTS = Path("/tmp/flashops-e2e-objects-{0}".format(os.getpid()))
|
||||
os.environ["FLASHOPS_DATABASE_URL"] = "sqlite+aiosqlite:///" + str(TEST_DB)
|
||||
os.environ["FLASHOPS_OBJECT_STORE_URL"] = str(TEST_OBJECTS)
|
||||
os.environ["FLASHOPS_TIME_SCALE"] = "0.01"
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from flashops_control.config import reset_settings
|
||||
from flashops_control.config import get_settings
|
||||
from flashops_control.main import app
|
||||
|
||||
|
||||
def setup_module():
|
||||
reset_settings()
|
||||
TEST_DB.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def teardown_module():
|
||||
TEST_DB.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_demo_run_recovers_and_builds_evidence():
|
||||
with TestClient(app) as client:
|
||||
health = client.get("/api/v1/health")
|
||||
assert health.status_code == 200
|
||||
|
||||
preflight = client.post("/api/v1/preflight", json={})
|
||||
assert preflight.status_code == 200
|
||||
assert preflight.json()["passed"] is True
|
||||
assert len(preflight.json()["checks"]) == 6
|
||||
|
||||
created = client.post(
|
||||
"/api/v1/runs",
|
||||
json={"loops": 4, "inject_failure": True, "created_by": "pytest"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
run = None
|
||||
for _ in range(100):
|
||||
response = client.get("/api/v1/runs/" + run_id)
|
||||
assert response.status_code == 200
|
||||
run = response.json()
|
||||
if run["state"] == "COMPLETED":
|
||||
break
|
||||
time.sleep(0.02)
|
||||
|
||||
assert run is not None
|
||||
assert run["state"] == "COMPLETED"
|
||||
assert run["verdict"] == "PASS"
|
||||
assert run["loop_done"] == 4
|
||||
assert run["recovery_count"] == 3
|
||||
assert run["evidence"]["completeness"] == 1.0
|
||||
assert Path(run["evidence"]["uri"], "manifest.json").exists()
|
||||
|
||||
events = client.get("/api/v1/runs/{0}/events".format(run_id)).json()
|
||||
kinds = [event["kind"] for event in events]
|
||||
assert [event["seq"] for event in events] == list(range(1, len(events) + 1))
|
||||
assert "HEARTBEAT_LOST" in kinds
|
||||
assert kinds.count("RECOVERY_STARTED") == 3
|
||||
assert "RECOVERY_SUCCEEDED" in kinds
|
||||
assert kinds[-1] == "RUN_COMPLETED"
|
||||
|
||||
|
||||
def test_mismatched_serial_is_rejected_before_run_creation():
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/preflight", json={"discovered_serial": "SYSTEM-DISK"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["passed"] is False
|
||||
serial = next(check for check in payload["checks"] if check["key"] == "serial")
|
||||
assert serial["passed"] is False
|
||||
|
||||
|
||||
def test_pause_resume_and_emergency_stop_are_audited():
|
||||
with TestClient(app) as client:
|
||||
created = client.post(
|
||||
"/api/v1/runs",
|
||||
json={"loops": 50, "inject_failure": False, "created_by": "pytest-actions"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
run = created.json()
|
||||
for _ in range(50):
|
||||
run = client.get("/api/v1/runs/" + run_id).json()
|
||||
if run["state"] == "RUNNING":
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert run["state"] == "RUNNING"
|
||||
|
||||
paused = client.post(
|
||||
"/api/v1/runs/{0}/pause".format(run_id),
|
||||
json={"actor": "pytest", "reason": "检查暂停"},
|
||||
)
|
||||
assert paused.status_code == 200
|
||||
assert paused.json()["state"] == "PAUSED"
|
||||
|
||||
resumed = client.post(
|
||||
"/api/v1/runs/{0}/resume".format(run_id),
|
||||
json={"actor": "pytest", "reason": "检查继续"},
|
||||
)
|
||||
assert resumed.status_code == 200
|
||||
assert resumed.json()["state"] == "RUNNING"
|
||||
|
||||
stopped = client.post(
|
||||
"/api/v1/runs/{0}/emergency-stop".format(run_id),
|
||||
json={"actor": "pytest", "reason": "检查急停"},
|
||||
)
|
||||
assert stopped.status_code == 200
|
||||
assert stopped.json()["state"] == "ABORTED"
|
||||
assert stopped.json()["human_touches"] == 3
|
||||
events = client.get("/api/v1/runs/{0}/events".format(run_id)).json()
|
||||
kinds = [event["kind"] for event in events]
|
||||
assert "RUN_PAUSED" in kinds
|
||||
assert "RUN_RESUMED" in kinds
|
||||
assert kinds[-1] == "RUN_ABORTED"
|
||||
|
||||
|
||||
def test_agent_registration_heartbeat_and_token_rotation():
|
||||
with TestClient(app) as client:
|
||||
registered = client.post(
|
||||
"/api/v1/agent/register",
|
||||
json={
|
||||
"host_name": "WS-05",
|
||||
"agent_version": "0.1.0-test",
|
||||
"os_family": "linux",
|
||||
"os_version": "pytest",
|
||||
"kernel": "test-kernel",
|
||||
"ip_address": "192.0.2.50",
|
||||
"toolchain": {"fio": "fio-3.36"},
|
||||
"labels": {"system_device_paths": ["/dev/nvme0n1"]},
|
||||
},
|
||||
)
|
||||
assert registered.status_code == 201
|
||||
credentials = registered.json()
|
||||
assert credentials["agent_id"].startswith("agent_")
|
||||
assert credentials["host_id"]
|
||||
assert len(credentials["token"]) >= 32
|
||||
|
||||
missing_token = client.post(
|
||||
"/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]),
|
||||
json={"healthy": True},
|
||||
)
|
||||
assert missing_token.status_code == 401
|
||||
|
||||
heartbeat = client.post(
|
||||
"/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]),
|
||||
headers={"Authorization": "Bearer " + credentials["token"]},
|
||||
json={
|
||||
"healthy": True,
|
||||
"agent_version": "0.1.1-test",
|
||||
"device_probe": {"nvme": ["nvme1n1"]},
|
||||
},
|
||||
)
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["ok"] is True
|
||||
assert heartbeat.json()["commands"] == []
|
||||
|
||||
rotated = client.post(
|
||||
"/api/v1/agent/register",
|
||||
json={
|
||||
"host_id": credentials["host_id"],
|
||||
"agent_version": "0.1.1-test",
|
||||
},
|
||||
)
|
||||
assert rotated.status_code == 201
|
||||
rotated_credentials = rotated.json()
|
||||
assert rotated_credentials["agent_id"] == credentials["agent_id"]
|
||||
assert rotated_credentials["token"] != credentials["token"]
|
||||
|
||||
old_token = client.post(
|
||||
"/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]),
|
||||
headers={"Authorization": "Bearer " + credentials["token"]},
|
||||
json={"healthy": True},
|
||||
)
|
||||
assert old_token.status_code == 401
|
||||
|
||||
new_token = client.post(
|
||||
"/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]),
|
||||
headers={"Authorization": "Bearer " + rotated_credentials["token"]},
|
||||
json={"healthy": False},
|
||||
)
|
||||
assert new_token.status_code == 200
|
||||
|
||||
hosts = client.get("/api/v1/agent/hosts")
|
||||
assert hosts.status_code == 200
|
||||
current = next(
|
||||
item for item in hosts.json() if item["host_id"] == credentials["host_id"]
|
||||
)
|
||||
assert current["agent_id"] == credentials["agent_id"]
|
||||
assert current["status"] == "DEGRADED"
|
||||
assert "token" not in current
|
||||
|
||||
|
||||
def _register_seed_agent(client):
|
||||
response = client.post(
|
||||
"/api/v1/agent/register",
|
||||
json={"host_name": "WS-05", "agent_version": "0.2.0-pytest"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
|
||||
def _idem_headers(credentials, key=None):
|
||||
return {
|
||||
"Authorization": "Bearer " + credentials["token"],
|
||||
"Idempotency-Key": key or str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
|
||||
def _lease(client, credentials):
|
||||
return client.post(
|
||||
"/api/v1/agent/{0}/lease".format(credentials["agent_id"]),
|
||||
headers={"Authorization": "Bearer " + credentials["token"]},
|
||||
json={"wait_timeout_s": 0},
|
||||
)
|
||||
|
||||
|
||||
def _ack(client, credentials, lease, key=None):
|
||||
return client.post(
|
||||
"/api/v1/agent/{0}/steps/{1}/ack".format(
|
||||
credentials["agent_id"], lease["step_id"]
|
||||
),
|
||||
headers=_idem_headers(credentials, key),
|
||||
json={"attempt": lease["attempt"]},
|
||||
)
|
||||
|
||||
|
||||
def _complete(client, credentials, lease, key=None):
|
||||
return client.post(
|
||||
"/api/v1/agent/{0}/steps/{1}/complete".format(
|
||||
credentials["agent_id"], lease["step_id"]
|
||||
),
|
||||
headers=_idem_headers(credentials, key),
|
||||
json={
|
||||
"attempt": lease["attempt"],
|
||||
"status": "SUCCEEDED",
|
||||
"exit_code": 0,
|
||||
"result": {"simulated": True},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_agent_step_lease_idempotency_and_full_dry_run():
|
||||
with TestClient(app) as client:
|
||||
credentials = _register_seed_agent(client)
|
||||
created = client.post(
|
||||
"/api/v1/runs",
|
||||
json={
|
||||
"loops": 2,
|
||||
"inject_failure": False,
|
||||
"execution_mode": "agent_dry_run",
|
||||
"created_by": "pytest-agent-lease",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
assert created.json()["state"] == "RUNNING"
|
||||
assert created.json()["execution_mode"] == "agent_dry_run"
|
||||
|
||||
unauthorized = client.post(
|
||||
"/api/v1/agent/{0}/lease".format(credentials["agent_id"]),
|
||||
json={"wait_timeout_s": 0},
|
||||
)
|
||||
assert unauthorized.status_code == 401
|
||||
|
||||
first = _lease(client, credentials)
|
||||
assert first.status_code == 200
|
||||
lease = first.json()
|
||||
assert lease["step_key"] == "flash-fw-b"
|
||||
assert lease["attempt"] == 1
|
||||
assert lease["dry_run"] is True
|
||||
|
||||
ack_key = str(uuid.uuid4())
|
||||
acked = _ack(client, credentials, lease, ack_key)
|
||||
assert acked.status_code == 200
|
||||
assert acked.json()["state"] == "RUNNING"
|
||||
replayed_ack = _ack(client, credentials, lease, ack_key)
|
||||
assert replayed_ack.status_code == 200
|
||||
assert replayed_ack.json()["replayed"] is True
|
||||
|
||||
renew = client.post(
|
||||
"/api/v1/agent/{0}/steps/{1}/renew".format(
|
||||
credentials["agent_id"], lease["step_id"]
|
||||
),
|
||||
headers=_idem_headers(credentials),
|
||||
json={"attempt": lease["attempt"]},
|
||||
)
|
||||
assert renew.status_code == 200
|
||||
|
||||
event_key = str(uuid.uuid4())
|
||||
output = client.post(
|
||||
"/api/v1/agent/{0}/steps/{1}/events".format(
|
||||
credentials["agent_id"], lease["step_id"]
|
||||
),
|
||||
headers=_idem_headers(credentials, event_key),
|
||||
json={
|
||||
"attempt": lease["attempt"],
|
||||
"events": [
|
||||
{"message": "dry-run output 1", "payload": {"line": 1}},
|
||||
{"message": "dry-run output 2", "severity": "DEBUG"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert output.status_code == 200
|
||||
assert output.json()["accepted"] == 2
|
||||
replayed_output = client.post(
|
||||
"/api/v1/agent/{0}/steps/{1}/events".format(
|
||||
credentials["agent_id"], lease["step_id"]
|
||||
),
|
||||
headers=_idem_headers(credentials, event_key),
|
||||
json={
|
||||
"attempt": lease["attempt"],
|
||||
"events": [
|
||||
{"message": "dry-run output 1", "payload": {"line": 1}},
|
||||
{"message": "dry-run output 2", "severity": "DEBUG"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert replayed_output.status_code == 200
|
||||
assert replayed_output.json()["replayed"] is True
|
||||
|
||||
complete_key = str(uuid.uuid4())
|
||||
completed = _complete(client, credentials, lease, complete_key)
|
||||
assert completed.status_code == 200
|
||||
replayed_complete = _complete(client, credentials, lease, complete_key)
|
||||
assert replayed_complete.status_code == 200
|
||||
assert replayed_complete.json()["replayed"] is True
|
||||
|
||||
step_count = 1
|
||||
while True:
|
||||
leased = _lease(client, credentials)
|
||||
if leased.status_code == 204:
|
||||
break
|
||||
assert leased.status_code == 200
|
||||
item = leased.json()
|
||||
assert _ack(client, credentials, item).status_code == 200
|
||||
assert _complete(client, credentials, item).status_code == 200
|
||||
step_count += 1
|
||||
|
||||
assert step_count == 7 # flash + (3 loop-body steps * 2 loops)
|
||||
run = client.get("/api/v1/runs/" + run_id).json()
|
||||
assert run["state"] == "COMPLETED"
|
||||
assert run["verdict"] == "PASS"
|
||||
assert run["loop_done"] == 2
|
||||
assert run["evidence"]["completeness"] == 1.0
|
||||
events = client.get("/api/v1/runs/{0}/events".format(run_id)).json()
|
||||
assert [event["seq"] for event in events] == list(range(1, len(events) + 1))
|
||||
assert sum(event["kind"] == "STEP_DISPATCHED" for event in events) == 7
|
||||
assert sum(event["kind"] == "STEP_SUCCEEDED" for event in events) == 7
|
||||
|
||||
|
||||
def test_expired_running_lease_retries_only_idempotent_steps():
|
||||
timing = get_settings().timing
|
||||
original_ttl = timing.lease_ttl_s
|
||||
object.__setattr__(timing, "lease_ttl_s", 0.01)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
credentials = _register_seed_agent(client)
|
||||
|
||||
# The first workflow step is a non-idempotent firmware action. Once
|
||||
# ACKed, an expired lease must freeze the run instead of rerunning it.
|
||||
created = client.post(
|
||||
"/api/v1/runs",
|
||||
json={"loops": 1, "execution_mode": "agent_dry_run"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
lease = _lease(client, credentials).json()
|
||||
assert lease["idempotent"] is False
|
||||
assert _ack(client, credentials, lease).status_code == 200
|
||||
time.sleep(0.02)
|
||||
assert _lease(client, credentials).status_code == 204
|
||||
frozen = client.get("/api/v1/runs/" + run_id).json()
|
||||
assert frozen["state"] == "FROZEN"
|
||||
assert frozen["verdict"] == "INCONCLUSIVE"
|
||||
|
||||
# On another run, finish the firmware step and let the idempotent fio
|
||||
# workload expire. It is safely reissued once with attempt=2.
|
||||
created_retry = client.post(
|
||||
"/api/v1/runs",
|
||||
json={"loops": 1, "execution_mode": "agent_dry_run"},
|
||||
)
|
||||
assert created_retry.status_code == 201
|
||||
retry_run_id = created_retry.json()["id"]
|
||||
firmware = _lease(client, credentials).json()
|
||||
assert _ack(client, credentials, firmware).status_code == 200
|
||||
assert _complete(client, credentials, firmware).status_code == 200
|
||||
workload = _lease(client, credentials).json()
|
||||
assert workload["step_key"] == "workload"
|
||||
assert workload["idempotent"] is True
|
||||
assert _ack(client, credentials, workload).status_code == 200
|
||||
time.sleep(0.02)
|
||||
retried = _lease(client, credentials)
|
||||
assert retried.status_code == 200
|
||||
assert retried.json()["step_id"] == workload["step_id"]
|
||||
assert retried.json()["attempt"] == 2
|
||||
stopped = client.post(
|
||||
"/api/v1/runs/{0}/emergency-stop".format(retry_run_id),
|
||||
json={"actor": "pytest", "reason": "清理重试场景"},
|
||||
)
|
||||
assert stopped.status_code == 200
|
||||
finally:
|
||||
object.__setattr__(timing, "lease_ttl_s", original_ttl)
|
||||
@@ -0,0 +1,40 @@
|
||||
from flashops_control.engine.recovery import RecoveryContext, next_recovery_level
|
||||
from flashops_control.enums import RecoveryLevel, RecoveryTrigger
|
||||
|
||||
|
||||
def test_integrity_failure_always_freezes():
|
||||
level = next_recovery_level(
|
||||
RecoveryContext(
|
||||
trigger=RecoveryTrigger.DATA_INTEGRITY,
|
||||
attempt=0,
|
||||
agent_online=True,
|
||||
host_network_online=True,
|
||||
oob_online=True,
|
||||
)
|
||||
)
|
||||
assert level == RecoveryLevel.FREEZE
|
||||
|
||||
|
||||
def test_offline_oob_removes_destructive_recovery_levels():
|
||||
level = next_recovery_level(
|
||||
RecoveryContext(
|
||||
trigger=RecoveryTrigger.HEARTBEAT_LOST,
|
||||
attempt=2,
|
||||
agent_online=True,
|
||||
host_network_online=True,
|
||||
oob_online=False,
|
||||
)
|
||||
)
|
||||
assert level == RecoveryLevel.FREEZE
|
||||
|
||||
|
||||
def test_recovery_escalates_in_order():
|
||||
context = dict(
|
||||
trigger=RecoveryTrigger.HEARTBEAT_LOST,
|
||||
agent_online=True,
|
||||
host_network_online=True,
|
||||
oob_online=True,
|
||||
)
|
||||
assert next_recovery_level(RecoveryContext(attempt=0, **context)) == RecoveryLevel.AGENT_SOFT
|
||||
assert next_recovery_level(RecoveryContext(attempt=1, **context)) == RecoveryLevel.OS_REBOOT
|
||||
assert next_recovery_level(RecoveryContext(attempt=2, **context)) == RecoveryLevel.OOB_RESET
|
||||
@@ -0,0 +1,39 @@
|
||||
from flashops_control.safety import PreflightRequest, run_preflight
|
||||
|
||||
|
||||
def valid_request(**overrides):
|
||||
values = {
|
||||
"expected_serial": "DUT-001",
|
||||
"discovered_serial": "DUT-001",
|
||||
"allow_destructive": True,
|
||||
"device_path": "/dev/nvme1n1",
|
||||
"system_device_paths": ["/dev/nvme0n1", "/dev/nvme0n1p2"],
|
||||
"firmware_model_allowed": True,
|
||||
"host_online": True,
|
||||
"oob_online": True,
|
||||
}
|
||||
values.update(overrides)
|
||||
return PreflightRequest(**values)
|
||||
|
||||
|
||||
def test_all_safety_gates_pass_for_bound_test_disk():
|
||||
result = run_preflight(valid_request())
|
||||
assert result.passed is True
|
||||
assert len(result.checks) == 6
|
||||
|
||||
|
||||
def test_serial_mismatch_is_rejected():
|
||||
result = run_preflight(valid_request(discovered_serial="WRONG-DISK"))
|
||||
assert result.passed is False
|
||||
assert next(check for check in result.checks if check.key == "serial").passed is False
|
||||
|
||||
|
||||
def test_system_partition_is_rejected():
|
||||
result = run_preflight(valid_request(device_path="/dev/nvme0n1p2"))
|
||||
assert result.passed is False
|
||||
assert next(check for check in result.checks if check.key == "system_disk").passed is False
|
||||
|
||||
|
||||
def test_destructive_opt_in_is_required():
|
||||
result = run_preflight(valid_request(allow_destructive=False))
|
||||
assert result.passed is False
|
||||
@@ -0,0 +1,22 @@
|
||||
import pytest
|
||||
|
||||
from flashops_control.engine.states import IllegalTransition, assert_run_transition
|
||||
from flashops_control.enums import RunState
|
||||
|
||||
|
||||
def test_happy_path_transitions_are_allowed():
|
||||
assert_run_transition(RunState.QUEUED.value, RunState.PREFLIGHT.value)
|
||||
assert_run_transition(RunState.PREFLIGHT.value, RunState.RUNNING.value)
|
||||
assert_run_transition(RunState.RUNNING.value, RunState.RECOVERING.value)
|
||||
assert_run_transition(RunState.RECOVERING.value, RunState.RUNNING.value)
|
||||
assert_run_transition(RunState.RUNNING.value, RunState.COMPLETED.value)
|
||||
|
||||
|
||||
def test_terminal_state_cannot_transition():
|
||||
with pytest.raises(IllegalTransition):
|
||||
assert_run_transition(RunState.COMPLETED.value, RunState.RUNNING.value)
|
||||
|
||||
|
||||
def test_rejected_cannot_be_resumed():
|
||||
with pytest.raises(IllegalTransition):
|
||||
assert_run_transition(RunState.REJECTED.value, RunState.QUEUED.value)
|
||||
@@ -0,0 +1,56 @@
|
||||
# FlashOps Host Agent
|
||||
|
||||
The Host Agent is a pull-mode process that runs on a test host. The first
|
||||
implemented slice supports:
|
||||
|
||||
- read-only host fingerprint discovery;
|
||||
- enrollment into a Test Host record;
|
||||
- one-time bearer token issuance;
|
||||
- local credential persistence with mode `0600`;
|
||||
- authenticated heartbeats and Host status updates;
|
||||
- server-side heartbeat aging to `DEGRADED` after 15s and `OFFLINE` after 30s;
|
||||
- optional gateway Basic Auth for deployments that enable an authenticated proxy.
|
||||
|
||||
Step leases, command templates, event uploads, artifacts, and completion
|
||||
reporting remain the next slice.
|
||||
|
||||
## Local run
|
||||
|
||||
Start the control plane in one terminal:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Register and send one heartbeat in another:
|
||||
|
||||
```bash
|
||||
make dev-agent
|
||||
```
|
||||
|
||||
Run continuously:
|
||||
|
||||
```bash
|
||||
make dev-agent-loop
|
||||
```
|
||||
|
||||
By default the Agent state is stored at `~/.flashops/agent-state.json`. It
|
||||
contains the raw bearer token and is created with mode `0600`. The control
|
||||
plane stores only its SHA-256 digest.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `FLASHOPS_AGENT_SERVER` | Control plane base URL |
|
||||
| `FLASHOPS_AGENT_HOST_ID` | Bind to an existing Test Host ID |
|
||||
| `FLASHOPS_AGENT_HOST_NAME` | Bind to or create a Test Host by name |
|
||||
| `FLASHOPS_AGENT_STATE_PATH` | Local credential state path |
|
||||
| `FLASHOPS_AGENT_ENROLLMENT_TOKEN` | First-registration secret |
|
||||
| `FLASHOPS_AGENT_BASIC_USER` | Optional authenticated-proxy user |
|
||||
| `FLASHOPS_AGENT_BASIC_PASSWORD` | Optional authenticated-proxy password |
|
||||
|
||||
Production registration is denied when the control plane does not have
|
||||
`FLASHOPS_AGENT_ENROLLMENT_TOKEN` configured. Store that value in
|
||||
`/etc/flashops/flashops.env` with root-only permissions; never put it in the
|
||||
systemd unit or repository.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""FlashOps pull-mode Host Agent."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Command-line entry point for the pull-mode Host Agent."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .client import AgentUnauthorized, ControlPlaneClient, ControlPlaneError
|
||||
from .fingerprint import heartbeat_payload, registration_payload
|
||||
from .state import AgentState, StateStore
|
||||
|
||||
|
||||
def _execute_dry_run(lease: dict) -> dict:
|
||||
"""Exercise the distributed worker path without invoking any host command."""
|
||||
|
||||
if lease.get("dry_run") is not True:
|
||||
raise RuntimeError("Agent 拒绝执行未注册的真实适配器")
|
||||
return {
|
||||
"simulated": True,
|
||||
"adapter": lease.get("adapter"),
|
||||
"template": lease.get("template"),
|
||||
"step_key": lease.get("step_key"),
|
||||
}
|
||||
|
||||
|
||||
def _process_one_lease(
|
||||
client: ControlPlaneClient, state: AgentState
|
||||
) -> Optional[str]:
|
||||
lease = client.lease(state.agent_id, state.token, wait_timeout_s=0)
|
||||
if lease is None:
|
||||
return None
|
||||
step_id = str(lease["step_id"])
|
||||
attempt = int(lease["attempt"])
|
||||
client.ack(
|
||||
state.agent_id,
|
||||
state.token,
|
||||
step_id,
|
||||
attempt,
|
||||
str(uuid.uuid4()),
|
||||
)
|
||||
client.report_events(
|
||||
state.agent_id,
|
||||
state.token,
|
||||
step_id,
|
||||
attempt,
|
||||
[{"message": "Agent dry-run 正在验证步骤协议", "severity": "INFO"}],
|
||||
str(uuid.uuid4()),
|
||||
)
|
||||
result = _execute_dry_run(lease)
|
||||
client.complete(
|
||||
state.agent_id,
|
||||
state.token,
|
||||
step_id,
|
||||
attempt,
|
||||
status="SUCCEEDED",
|
||||
exit_code=0,
|
||||
result=result,
|
||||
idempotency_key=str(uuid.uuid4()),
|
||||
)
|
||||
return str(lease.get("step_key") or step_id)
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="FlashOps Host Agent")
|
||||
parser.add_argument(
|
||||
"--server",
|
||||
default=os.environ.get("FLASHOPS_AGENT_SERVER", "http://127.0.0.1:8000"),
|
||||
)
|
||||
parser.add_argument("--host-id", default=os.environ.get("FLASHOPS_AGENT_HOST_ID"))
|
||||
parser.add_argument("--host-name", default=os.environ.get("FLASHOPS_AGENT_HOST_NAME"))
|
||||
parser.add_argument(
|
||||
"--state-path",
|
||||
type=Path,
|
||||
default=Path(
|
||||
os.environ.get(
|
||||
"FLASHOPS_AGENT_STATE_PATH",
|
||||
str(Path.home() / ".flashops" / "agent-state.json"),
|
||||
)
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enrollment-token",
|
||||
default=os.environ.get("FLASHOPS_AGENT_ENROLLMENT_TOKEN"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--basic-user",
|
||||
default=os.environ.get("FLASHOPS_AGENT_BASIC_USER"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--basic-password",
|
||||
default=os.environ.get("FLASHOPS_AGENT_BASIC_PASSWORD"),
|
||||
)
|
||||
parser.add_argument("--interval", type=float, default=None)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def _basic_auth(user: Optional[str], password: Optional[str]) -> Optional[Tuple[str, str]]:
|
||||
if bool(user) != bool(password):
|
||||
raise ValueError("Basic Auth 用户名和密码必须同时提供")
|
||||
return (user, password) if user and password else None
|
||||
|
||||
|
||||
def _register(
|
||||
client: ControlPlaneClient,
|
||||
store: StateStore,
|
||||
server: str,
|
||||
host_id: Optional[str],
|
||||
host_name: Optional[str],
|
||||
) -> AgentState:
|
||||
payload = client.register(
|
||||
registration_payload(host_id=host_id, host_name=host_name)
|
||||
)
|
||||
state = AgentState.from_payload(server, payload)
|
||||
store.save(state)
|
||||
print("Agent 已注册: {0} → Host {1}".format(state.agent_id, state.host_id))
|
||||
return state
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parser().parse_args()
|
||||
server = args.server.rstrip("/")
|
||||
store = StateStore(args.state_path)
|
||||
try:
|
||||
basic_auth = _basic_auth(args.basic_user, args.basic_password)
|
||||
with ControlPlaneClient(
|
||||
server,
|
||||
enrollment_token=args.enrollment_token,
|
||||
basic_auth=basic_auth,
|
||||
) as client:
|
||||
state = store.load()
|
||||
if state is None or state.server != server:
|
||||
state = _register(
|
||||
client, store, server, args.host_id, args.host_name
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = client.heartbeat(
|
||||
state.agent_id,
|
||||
state.token,
|
||||
heartbeat_payload(),
|
||||
)
|
||||
except AgentUnauthorized:
|
||||
state = _register(
|
||||
client, store, server, args.host_id, args.host_name
|
||||
)
|
||||
response = client.heartbeat(
|
||||
state.agent_id,
|
||||
state.token,
|
||||
heartbeat_payload(),
|
||||
)
|
||||
|
||||
print(
|
||||
"心跳已确认: {0} · commands={1}".format(
|
||||
response.get("server_time"),
|
||||
len(response.get("commands", [])),
|
||||
)
|
||||
)
|
||||
completed_step = _process_one_lease(client, state)
|
||||
if completed_step:
|
||||
print("dry-run 步骤已完成: {0}".format(completed_step))
|
||||
if args.once:
|
||||
return 0
|
||||
interval = args.interval or state.heartbeat_interval_s
|
||||
time.sleep(max(0.5, interval))
|
||||
except KeyboardInterrupt:
|
||||
print("Agent 已停止")
|
||||
return 0
|
||||
except (ControlPlaneError, OSError, RuntimeError, ValueError) as exc:
|
||||
print("Agent 错误: {0}".format(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Small HTTP client for the control-plane Agent contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class ControlPlaneError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class AgentUnauthorized(ControlPlaneError):
|
||||
pass
|
||||
|
||||
|
||||
class StepLeaseConflict(ControlPlaneError):
|
||||
pass
|
||||
|
||||
|
||||
class ControlPlaneClient:
|
||||
def __init__(
|
||||
self,
|
||||
server: str,
|
||||
*,
|
||||
enrollment_token: Optional[str] = None,
|
||||
basic_auth: Optional[Tuple[str, str]] = None,
|
||||
timeout_s: float = 15.0,
|
||||
transport: Optional[httpx.BaseTransport] = None,
|
||||
) -> None:
|
||||
self.server = server.rstrip("/")
|
||||
self.enrollment_token = enrollment_token
|
||||
self._client = httpx.Client(
|
||||
base_url=self.server,
|
||||
auth=httpx.BasicAuth(*basic_auth) if basic_auth else None,
|
||||
timeout=timeout_s,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "ControlPlaneClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
|
||||
@staticmethod
|
||||
def _payload(response: httpx.Response) -> Dict[str, Any]:
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError as exc:
|
||||
raise ControlPlaneError(
|
||||
"控制平面返回了非 JSON 响应: HTTP {0}".format(response.status_code)
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ControlPlaneError("控制平面响应不是 JSON object")
|
||||
return value
|
||||
|
||||
def register(self, fingerprint: Dict[str, Any]) -> Dict[str, Any]:
|
||||
headers = {}
|
||||
if self.enrollment_token:
|
||||
headers["X-FlashOps-Enrollment-Token"] = self.enrollment_token
|
||||
response = self._client.post(
|
||||
"/api/v1/agent/register",
|
||||
json=fingerprint,
|
||||
headers=headers,
|
||||
)
|
||||
if response.status_code == 401:
|
||||
raise AgentUnauthorized("Agent 注册口令无效")
|
||||
if response.status_code >= 400:
|
||||
raise ControlPlaneError(
|
||||
"Agent 注册失败: HTTP {0} {1}".format(
|
||||
response.status_code, response.text[:200]
|
||||
)
|
||||
)
|
||||
payload = self._payload(response)
|
||||
for field in ("agent_id", "host_id", "token"):
|
||||
if not payload.get(field):
|
||||
raise ControlPlaneError("注册响应缺少字段: {0}".format(field))
|
||||
return payload
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
agent_id: str,
|
||||
token: str,
|
||||
heartbeat: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
response = self._client.post(
|
||||
"/api/v1/agent/{0}/heartbeat".format(agent_id),
|
||||
json=heartbeat,
|
||||
headers={"Authorization": "Bearer " + token},
|
||||
)
|
||||
if response.status_code == 401:
|
||||
raise AgentUnauthorized("Agent token 已失效")
|
||||
if response.status_code >= 400:
|
||||
raise ControlPlaneError(
|
||||
"Agent 心跳失败: HTTP {0} {1}".format(
|
||||
response.status_code, response.text[:200]
|
||||
)
|
||||
)
|
||||
payload = self._payload(response)
|
||||
if payload.get("ok") is not True:
|
||||
raise ControlPlaneError("控制平面未确认心跳")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _agent_headers(token: str, idempotency_key: Optional[str] = None) -> Dict[str, str]:
|
||||
headers = {"Authorization": "Bearer " + token}
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
return headers
|
||||
|
||||
def lease(
|
||||
self,
|
||||
agent_id: str,
|
||||
token: str,
|
||||
*,
|
||||
wait_timeout_s: float = 0.0,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
response = self._client.post(
|
||||
"/api/v1/agent/{0}/lease".format(agent_id),
|
||||
json={"wait_timeout_s": wait_timeout_s},
|
||||
headers=self._agent_headers(token),
|
||||
)
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
return self._step_response(response, "领取步骤")
|
||||
|
||||
def _step_response(self, response: httpx.Response, action: str) -> Dict[str, Any]:
|
||||
if response.status_code == 401:
|
||||
raise AgentUnauthorized("Agent token 已失效")
|
||||
if response.status_code == 409:
|
||||
detail = response.text[:300]
|
||||
try:
|
||||
detail = str(response.json().get("detail", detail))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
raise StepLeaseConflict("{0}冲突: {1}".format(action, detail))
|
||||
if response.status_code >= 400:
|
||||
raise ControlPlaneError(
|
||||
"{0}失败: HTTP {1} {2}".format(
|
||||
action, response.status_code, response.text[:200]
|
||||
)
|
||||
)
|
||||
return self._payload(response)
|
||||
|
||||
def _step_post(
|
||||
self,
|
||||
agent_id: str,
|
||||
token: str,
|
||||
step_id: str,
|
||||
action: str,
|
||||
payload: Dict[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
response = self._client.post(
|
||||
"/api/v1/agent/{0}/steps/{1}/{2}".format(
|
||||
agent_id, step_id, action
|
||||
),
|
||||
json=payload,
|
||||
headers=self._agent_headers(token, idempotency_key),
|
||||
)
|
||||
return self._step_response(response, action)
|
||||
|
||||
def ack(
|
||||
self,
|
||||
agent_id: str,
|
||||
token: str,
|
||||
step_id: str,
|
||||
attempt: int,
|
||||
idempotency_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
return self._step_post(
|
||||
agent_id,
|
||||
token,
|
||||
step_id,
|
||||
"ack",
|
||||
{"attempt": attempt},
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
def renew(
|
||||
self,
|
||||
agent_id: str,
|
||||
token: str,
|
||||
step_id: str,
|
||||
attempt: int,
|
||||
idempotency_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
return self._step_post(
|
||||
agent_id,
|
||||
token,
|
||||
step_id,
|
||||
"renew",
|
||||
{"attempt": attempt},
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
def report_events(
|
||||
self,
|
||||
agent_id: str,
|
||||
token: str,
|
||||
step_id: str,
|
||||
attempt: int,
|
||||
events: List[Dict[str, Any]],
|
||||
idempotency_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
return self._step_post(
|
||||
agent_id,
|
||||
token,
|
||||
step_id,
|
||||
"events",
|
||||
{"attempt": attempt, "events": events},
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
agent_id: str,
|
||||
token: str,
|
||||
step_id: str,
|
||||
attempt: int,
|
||||
*,
|
||||
status: str,
|
||||
idempotency_key: str,
|
||||
exit_code: Optional[int] = None,
|
||||
error_class: Optional[str] = None,
|
||||
result: Optional[Dict[str, Any]] = None,
|
||||
log_uri: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return self._step_post(
|
||||
agent_id,
|
||||
token,
|
||||
step_id,
|
||||
"complete",
|
||||
{
|
||||
"attempt": attempt,
|
||||
"status": status,
|
||||
"exit_code": exit_code,
|
||||
"error_class": error_class,
|
||||
"result": result or {},
|
||||
"log_uri": log_uri,
|
||||
},
|
||||
idempotency_key,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Read-only host discovery used during registration and heartbeat."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import __version__
|
||||
|
||||
|
||||
def _ip_address() -> Optional[str]:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.connect(("192.0.2.1", 9))
|
||||
return str(sock.getsockname()[0])
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _tool_version(command: str, args: List[str]) -> Optional[str]:
|
||||
executable = shutil.which(command)
|
||||
if not executable:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[executable] + args,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
output = (result.stdout or result.stderr).strip().splitlines()
|
||||
return output[0][:128] if output else None
|
||||
|
||||
|
||||
def discover_toolchain() -> Dict[str, str]:
|
||||
probes = {
|
||||
"fio": ("fio", ["--version"]),
|
||||
"nvme-cli": ("nvme", ["version"]),
|
||||
"smartctl": ("smartctl", ["--version"]),
|
||||
}
|
||||
versions: Dict[str, str] = {}
|
||||
for name, (command, args) in probes.items():
|
||||
version = _tool_version(command, args)
|
||||
if version:
|
||||
versions[name] = version
|
||||
return versions
|
||||
|
||||
|
||||
def system_device_paths() -> List[str]:
|
||||
if platform.system().lower() != "linux":
|
||||
return []
|
||||
try:
|
||||
for line in Path("/proc/mounts").read_text(encoding="utf-8").splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) >= 2 and fields[1] == "/" and fields[0].startswith("/dev/"):
|
||||
return [os.path.realpath(fields[0])]
|
||||
except OSError:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def registration_payload(
|
||||
*,
|
||||
host_id: Optional[str] = None,
|
||||
host_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"host_id": host_id,
|
||||
"host_name": host_name or platform.node() or socket.gethostname(),
|
||||
"agent_version": __version__,
|
||||
"os_family": platform.system().lower() or "unknown",
|
||||
"os_version": platform.platform(),
|
||||
"kernel": platform.release(),
|
||||
"cpu": platform.processor() or None,
|
||||
"motherboard": None,
|
||||
"bios_version": None,
|
||||
"ip_address": _ip_address(),
|
||||
"toolchain": discover_toolchain(),
|
||||
"labels": {"system_device_paths": system_device_paths()},
|
||||
}
|
||||
|
||||
|
||||
def heartbeat_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"healthy": True,
|
||||
"agent_version": __version__,
|
||||
"ip_address": _ip_address(),
|
||||
"toolchain": discover_toolchain(),
|
||||
"device_probe": {"system_device_paths": system_device_paths()},
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Agent credential persistence with restrictive local permissions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentState:
|
||||
server: str
|
||||
agent_id: str
|
||||
host_id: str
|
||||
token: str
|
||||
heartbeat_interval_s: float = 5.0
|
||||
|
||||
@classmethod
|
||||
def from_payload(
|
||||
cls,
|
||||
server: str,
|
||||
payload: Dict[str, Any],
|
||||
) -> "AgentState":
|
||||
return cls(
|
||||
server=server.rstrip("/"),
|
||||
agent_id=str(payload["agent_id"]),
|
||||
host_id=str(payload["host_id"]),
|
||||
token=str(payload["token"]),
|
||||
heartbeat_interval_s=float(payload.get("heartbeat_interval_s", 5.0)),
|
||||
)
|
||||
|
||||
|
||||
class StateStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
|
||||
def load(self) -> Optional[AgentState]:
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
return AgentState(**raw)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("Agent state 文件无效: {0}".format(self.path)) from exc
|
||||
|
||||
def save(self, state: AgentState) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
descriptor = os.open(
|
||||
str(temporary),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
json.dump(asdict(state), handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self.path)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
@@ -0,0 +1,141 @@
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from flashops_agent.client import AgentUnauthorized, ControlPlaneClient
|
||||
from flashops_agent.state import AgentState, StateStore
|
||||
|
||||
|
||||
def test_control_plane_client_registers_and_sends_heartbeat():
|
||||
requests = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/agent/register":
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"agent_id": "agent_test",
|
||||
"host_id": "host_test",
|
||||
"token": "raw-token",
|
||||
"heartbeat_interval_s": 5,
|
||||
},
|
||||
)
|
||||
if request.url.path == "/api/v1/agent/agent_test/heartbeat":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"server_time": "2026-07-27T00:00:00Z",
|
||||
"commands": [],
|
||||
},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
with ControlPlaneClient(
|
||||
"http://control.test",
|
||||
enrollment_token="enroll-test",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
registered = client.register(
|
||||
{
|
||||
"host_name": "pytest-host",
|
||||
"agent_version": "0.1.0",
|
||||
"os_family": "linux",
|
||||
}
|
||||
)
|
||||
heartbeat = client.heartbeat(
|
||||
registered["agent_id"],
|
||||
registered["token"],
|
||||
{"healthy": True},
|
||||
)
|
||||
|
||||
assert heartbeat["ok"] is True
|
||||
assert requests[0].headers["x-flashops-enrollment-token"] == "enroll-test"
|
||||
assert requests[1].headers["authorization"] == "Bearer raw-token"
|
||||
|
||||
|
||||
def test_control_plane_client_rejects_unauthorized_heartbeat():
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"detail": "invalid"})
|
||||
|
||||
with ControlPlaneClient(
|
||||
"http://control.test",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
with pytest.raises(AgentUnauthorized):
|
||||
client.heartbeat("agent_test", "bad-token", {"healthy": True})
|
||||
|
||||
|
||||
def test_state_store_round_trip_uses_private_permissions(tmp_path: Path):
|
||||
path = tmp_path / "agent-state.json"
|
||||
store = StateStore(path)
|
||||
expected = AgentState(
|
||||
server="http://control.test",
|
||||
agent_id="agent_test",
|
||||
host_id="host_test",
|
||||
token="raw-token",
|
||||
heartbeat_interval_s=5.0,
|
||||
)
|
||||
|
||||
store.save(expected)
|
||||
|
||||
assert store.load() == expected
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_control_plane_client_runs_step_lease_protocol():
|
||||
requests = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
path = request.url.path
|
||||
if path.endswith("/lease"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"step_id": "step_test", "attempt": 1, "dry_run": True},
|
||||
)
|
||||
if path.endswith("/ack"):
|
||||
return httpx.Response(200, json={"step_id": "step_test", "state": "RUNNING"})
|
||||
if path.endswith("/renew"):
|
||||
return httpx.Response(200, json={"step_id": "step_test", "state": "RUNNING"})
|
||||
if path.endswith("/events"):
|
||||
return httpx.Response(200, json={"step_id": "step_test", "accepted": 1})
|
||||
if path.endswith("/complete"):
|
||||
return httpx.Response(200, json={"step_id": "step_test", "state": "SUCCEEDED"})
|
||||
return httpx.Response(404)
|
||||
|
||||
token = "raw-token"
|
||||
keys = [str(uuid.uuid4()) for _ in range(4)]
|
||||
with ControlPlaneClient(
|
||||
"http://control.test", transport=httpx.MockTransport(handler)
|
||||
) as client:
|
||||
lease = client.lease("agent_test", token)
|
||||
assert lease is not None
|
||||
client.ack("agent_test", token, "step_test", 1, keys[0])
|
||||
client.renew("agent_test", token, "step_test", 1, keys[1])
|
||||
client.report_events(
|
||||
"agent_test",
|
||||
token,
|
||||
"step_test",
|
||||
1,
|
||||
[{"message": "hello"}],
|
||||
keys[2],
|
||||
)
|
||||
client.complete(
|
||||
"agent_test",
|
||||
token,
|
||||
"step_test",
|
||||
1,
|
||||
status="SUCCEEDED",
|
||||
exit_code=0,
|
||||
result={"simulated": True},
|
||||
idempotency_key=keys[3],
|
||||
)
|
||||
|
||||
assert len(requests) == 5
|
||||
assert requests[0].headers["authorization"] == "Bearer raw-token"
|
||||
assert requests[1].headers["idempotency-key"] == keys[0]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
@keyframes omPulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||
</style>
|
||||
</helmet>
|
||||
<dc-import name="ConsoleNav" active="assets" hint-size="100%,92px"></dc-import>
|
||||
<div data-screen-label="资产中心" style="max-width:1400px;margin:0 auto;padding:22px 24px 64px;font-family:var(--font-body);animation:omIn .5s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;margin-bottom:18px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.18em;color:var(--color-accent-700);font-weight:500">07 · ASSETS</div>
|
||||
<h2 style="margin:2px 0 0;font-size:30px">资产中心</h2>
|
||||
<div class="text-muted" style="font-size:12.5px;margin-top:2px">登记 · 绑定 · 禁用 · 审计 — 任何破坏性任务必须绑定允许的 DUT</div>
|
||||
</div>
|
||||
<span style="margin-left:auto"></span>
|
||||
<button class="btn btn-primary" onClick="{{ openReg }}" style="gap:7px">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||
登记资产
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="seg" style="background:var(--color-bg);margin-bottom:16px">
|
||||
<label class="seg-opt"><input type="radio" name="atab" checked="{{ tHost }}" onChange="{{ setHost }}"><span>测试主机 6</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="atab" checked="{{ tDut }}" onChange="{{ setDut }}"><span>DUT 8</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="atab" checked="{{ tFw }}" onChange="{{ setFw }}"><span>固件包 6</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="atab" checked="{{ tTool }}" onChange="{{ setTool }}"><span>工具连接器 9</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="atab" checked="{{ tOob }}" onChange="{{ setOob }}"><span>带外控制器 6</span></label>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ tHost }}" hint-placeholder-val="{{ false }}">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px;animation:omIn .35s cubic-bezier(.2,.7,.2,1) both" data-screen-label="资产·主机">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<table class="table">
|
||||
<thead><tr><th>主机</th><th>平台 / OS</th><th>BIOS</th><th>Agent</th><th>心跳</th><th>带外配对</th><th>绑定工位</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">HOST-A01</td><td style="font-size:12.5px">x86 台式 · Win11 22631</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">F26b</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.4.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">2s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">OOB-01</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-01</td><td><span class="tag tag-accent">运行中</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">审计</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">HOST-A02</td><td style="font-size:12.5px">x86 台式 · Win11 22631</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">F26b</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.4.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">1s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">OOB-02</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-02</td><td><span class="tag tag-accent">运行中</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">审计</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">HOST-L02</td><td style="font-size:12.5px">2U 服务器 · Ubuntu 22.04 · 6.5.0-41</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">2.1a</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.4.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:#8a2e22">丢失 1m+</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">OOB-03</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-03</td><td><span style="font-size:10.5px;padding:3px 9px;background:var(--color-accent-200);color:var(--color-accent-800);animation:omPulse 1.2s infinite">恢复中 L3</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">审计</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">HOST-L01</td><td style="font-size:12.5px">2U 服务器 · Ubuntu 22.04 · 6.5.0-41</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">2.1a</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.4.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">3s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">OOB-04</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-04</td><td><span class="tag tag-outline">空闲</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">审计</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">HOST-A03</td><td style="font-size:12.5px">x86 台式 · Win11 22631</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">F26b</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.4.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">2s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">OOB-05</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-05</td><td><span class="tag tag-accent">已预约</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">审计</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700;color:var(--color-neutral-600)">HOST-W02</td><td style="font-size:12.5px;color:var(--color-neutral-600)">x86 台式 · Win10 19045</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.8c</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.3.9</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">—</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">OOB-06 升级中</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-06</td><td><span style="font-size:10.5px;padding:3px 9px;border:1px solid var(--color-divider);color:var(--color-neutral-600);background:repeating-linear-gradient(135deg,rgba(29,31,32,.10) 0 2px,transparent 2px 5px)">维护</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">审计</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:var(--color-neutral-600)">主机镜像:golden-image-win11-v7 / ubuntu2204-v4 · 组策略固化(禁用快速启动/自动更新/睡眠劫持) · Agent 自愈:本地看门狗 + 双进程互监,崩溃 10s 内拉起且证据不丢</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ tDut }}" hint-placeholder-val="{{ true }}">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px;animation:omIn .35s cubic-bezier(.2,.7,.2,1) both" data-screen-label="资产·DUT">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<table class="table">
|
||||
<thead><tr><th>序列号</th><th>型号 / 形态</th><th>当前固件</th><th style="width:130px">P/E 磨损</th><th>允许动作</th><th>绑定</th><th>生命周期</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">SAMPLE-001</td><td style="font-size:12.5px">NV-E1T92 · 1.92TB U.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">FW_3.3.0-rc2 · slot2</td><td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="height:100%;width:23%;background:var(--color-accent)"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px">23%</span></div></td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-accent-800);color:#f2f2f3;padding:2px 7px">破坏写</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px">刷写</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-01 · 运行中</td><td><span class="tag tag-outline">测试专用</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">履历</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">SAMPLE-002</td><td style="font-size:12.5px">NV-E1T92 · 1.92TB U.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">FW_3.3.0-rc2 · slot2</td><td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="height:100%;width:31%;background:var(--color-accent)"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px">31%</span></div></td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-accent-800);color:#f2f2f3;padding:2px 7px">破坏写</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px">刷写</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-03 · 冻结</td><td><span class="tag tag-outline">测试专用</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">履历</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">SAMPLE-003</td><td style="font-size:12.5px">NV-E960 · 960GB M.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">FW_3.2.1 · slot1</td><td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="height:100%;width:8%;background:var(--color-accent)"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px">8%</span></div></td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-accent-800);color:#f2f2f3;padding:2px 7px">破坏写</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px">刷写</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">未绑定</td><td><span class="tag tag-outline">测试专用</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">履历</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">SAMPLE-004</td><td style="font-size:12.5px">NV-E1T92 · 1.92TB U.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">FW_3.2.1 · slot1</td><td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="height:100%;width:67%;background:var(--color-accent-700)"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px">67%</span></div></td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-accent-800);color:#f2f2f3;padding:2px 7px">破坏写</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-02 · 运行中</td><td><span class="tag tag-outline">测试专用</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">履历</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">SAMPLE-005</td><td style="font-size:12.5px">NV-E1T92 · 1.92TB U.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">FW_3.1.9 · slot1</td><td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="height:100%;width:89%;background:#b03d2e"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:#8a2e22">89%</span></div></td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px;color:var(--color-neutral-600)">只读</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">未绑定</td><td><span style="font-size:10.5px;padding:2px 9px;border:1px solid #b03d2e;color:#8a2e22">寿命预警</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">履历</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">SAMPLE-006</td><td style="font-size:12.5px">NV-E1T92 · 1.92TB U.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">FW_3.2.1 · slot1</td><td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="height:100%;width:12%;background:var(--color-accent)"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px">12%</span></div></td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-accent-800);color:#f2f2f3;padding:2px 7px">破坏写</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px">刷写</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-05 · 已预约</td><td><span class="tag tag-outline">测试专用</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">履历</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">SAMPLE-007</td><td style="font-size:12.5px">NV-E3T84 · 3.84TB U.2 工程样</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">FW_3.3.0-rc3 · slot2</td><td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="height:100%;width:4%;background:var(--color-accent)"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px">4%</span></div></td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px">刷写</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px;color:var(--color-neutral-600)">只读</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">收货检验中</td><td><span class="tag tag-neutral">入库检</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">履历</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700;color:var(--color-neutral-500)">SAMPLE-000</td><td style="font-size:12.5px;color:var(--color-neutral-500)">NV-E960 · 960GB M.2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-neutral-500)">—</td><td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider);background:repeating-linear-gradient(135deg,rgba(29,31,32,.10) 0 2px,transparent 2px 5px)"></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">100%</span></div></td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px dashed var(--color-neutral-400);padding:2px 7px;color:var(--color-neutral-500)">禁用</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-neutral-500)">—</td><td><span class="tag tag-neutral">已退役</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">履历</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:var(--color-neutral-600)">危险任务需序列号 + BDF/设备路径 + 允许标签三信号一致才执行 · 命中系统盘立即拒绝</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ tFw }}" hint-placeholder-val="{{ false }}">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px;animation:omIn .35s cubic-bezier(.2,.7,.2,1) both" data-screen-label="资产·固件">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<table class="table">
|
||||
<thead><tr><th>版本</th><th>SHA256</th><th>来源</th><th>兼容型号</th><th>刷写工具</th><th>审批</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">FW_3.3.0-rc3</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">9f2c…41aa</td><td style="font-size:12.5px">固件组 CI #4471</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">NV-E1T92 / E3T84</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">vendor_flash 2.4</td><td><span style="font-size:10.5px;padding:2px 9px;border:1px solid #b03d2e;color:#8a2e22">待审批</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">审批</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">FW_3.3.0-rc2</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">7d18…c930</td><td style="font-size:12.5px">固件组 CI #4402</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">NV-E1T92</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">vendor_flash 2.4</td><td><span class="tag tag-accent">已审批</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">详情</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">FW_3.3.0-rc1</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">55ab…0f77</td><td style="font-size:12.5px">固件组 CI #4359</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">NV-E1T92</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">vendor_flash 2.4</td><td><span class="tag tag-neutral">已归档</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">详情</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">FW_3.2.1</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">4bd1…08ce</td><td style="font-size:12.5px">发布库 · 量产基线</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">NV-E1T92 / E960</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">vendor_flash 2.4</td><td><span class="tag tag-accent">已审批 · 基线</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">详情</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">FW_3.1.9</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">e0c2…7b14</td><td style="font-size:12.5px">发布库</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">NV-E1T92 / E960</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">vendor_flash 2.3</td><td><span class="tag tag-neutral">已归档</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">详情</button></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">FW_2.9.4-eng</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1a9d…3c58</td><td style="font-size:12.5px">工程调试版</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">NV-E3T84</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">vendor_flash 2.4</td><td><span style="font-size:10.5px;padding:2px 9px;border:1px dashed var(--color-neutral-500);color:var(--color-neutral-700)">仅实验工位</span></td><td><button class="btn btn-ghost" style="font-size:11.5px">详情</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:var(--color-neutral-600)">固件哈希在登记与刷写前双重校验 · 刷写命令只能来自签名模板</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ tTool }}" hint-placeholder-val="{{ false }}">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px;animation:omIn .35s cubic-bezier(.2,.7,.2,1) both" data-screen-label="资产·工具连接器">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<table class="table">
|
||||
<thead><tr><th>连接器</th><th>接入方式</th><th>版本</th><th>risk_level</th><th>healthcheck</th><th>复用</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="font-size:13px;font-weight:500">nvme-cli</td><td><span class="tag tag-neutral">CLI</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">2.9.1 · conn 1.2</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px;color:var(--color-neutral-600)">只读+管理</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">✓ 4m 前</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">3 客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">配置</button></td></tr>
|
||||
<tr><td style="font-size:13px;font-weight:500">fio</td><td><span class="tag tag-neutral">CLI</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">3.36 · conn 1.4</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-neutral-200);padding:2px 7px">写入</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">✓ 4m 前</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">3 客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">配置</button></td></tr>
|
||||
<tr><td style="font-size:13px;font-weight:500">vendor_flash(私有刷写)</td><td><span class="tag tag-neutral">CLI</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">2.4 · conn 0.9</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-accent-800);color:#f2f2f3;padding:2px 7px">固件更新</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">✓ 12m 前</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">本客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">配置</button></td></tr>
|
||||
<tr style="background:#f7e9e6">
|
||||
<td style="font-size:13px;font-weight:500">MPTool 量产工具</td><td><span style="font-size:10.5px;padding:2px 9px;border:1px solid #b03d2e;color:#8a2e22">GUI-wrap</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">5.1 · pywinauto 0.3</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid #b03d2e;color:#8a2e22;padding:2px 7px">格式化+刷写</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:#8a2e22">受控例外</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">本客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">风险预案</button></td></tr>
|
||||
<tr><td style="font-size:13px;font-weight:500">smartctl</td><td><span class="tag tag-neutral">CLI</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">7.4 · conn 1.1</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px;color:var(--color-neutral-600)">只读</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">✓ 4m 前</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">3 客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">配置</button></td></tr>
|
||||
<tr><td style="font-size:13px;font-weight:500">PyNVMe3 执行器</td><td><span class="tag tag-accent">Python API</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">24.6 · conn 0.7β</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-neutral-200);padding:2px 7px">写入</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">✓ 1h 前</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">2 客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">配置</button></td></tr>
|
||||
<tr><td style="font-size:13px;font-weight:500">SANBlaze REST 桥</td><td><span class="tag tag-accent">REST API</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">10.5 · conn 0.5β</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:var(--color-neutral-200);padding:2px 7px">写入+注错</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-neutral-500)">未接入设备</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1 客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">配置</button></td></tr>
|
||||
<tr><td style="font-size:13px;font-weight:500">blktrace 采集</td><td><span class="tag tag-neutral">CLI</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.3 · conn 1.0</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px;color:var(--color-neutral-600)">只读</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">✓ 4m 前</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">2 客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">配置</button></td></tr>
|
||||
<tr><td style="font-size:13px;font-weight:500">私有日志抓取 log_dump</td><td><span class="tag tag-neutral">CLI</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1.8 · conn 0.9</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 7px;color:var(--color-neutral-600)">只读</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">✓ 9m 前</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">本客户</td><td><button class="btn btn-ghost" style="font-size:11.5px">配置</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:var(--color-neutral-600)">连接器接口:discover / precheck / execute / collect / cancel / health / normalize_result · 输入输出受 JSON Schema 约束 · 复用率目标 >60%</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ tOob }}" hint-placeholder-val="{{ false }}">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px;animation:omIn .35s cubic-bezier(.2,.7,.2,1) both" data-screen-label="资产·带外控制器">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<table class="table">
|
||||
<thead><tr><th>控制器</th><th>硬件</th><th>固件</th><th>通道能力</th><th>独立心跳</th><th>配对主机</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">OOB-01</td><td style="font-size:12.5px">RPi 5 + 光耦 ATX 板</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">oob-agent 0.9.4</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">PWR</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">RST</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">HDMI</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">温度</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">1s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">HOST-A01</td><td><span class="tag tag-accent">在线</span></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">OOB-02</td><td style="font-size:12.5px">RPi 5 + 光耦 ATX 板</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">oob-agent 0.9.4</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">PWR</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">RST</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">温度</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">1s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">HOST-A02</td><td><span class="tag tag-accent">在线</span></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">OOB-03</td><td style="font-size:12.5px">JetKVM + ATX 扩展</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">adapter 0.4</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">PWR</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">RST</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">KVM画面</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">1s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">HOST-L02</td><td><span style="font-size:10.5px;padding:3px 9px;background:var(--color-accent-200);color:var(--color-accent-800);animation:omPulse 1.2s infinite">执行 L3</span></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">OOB-04</td><td style="font-size:12.5px">PiKVM v4 + ATX</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">adapter 0.4</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">PWR</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">RST</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">KVM画面</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">串口</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">2s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">HOST-L01</td><td><span class="tag tag-accent">在线</span></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">OOB-05</td><td style="font-size:12.5px">RPi CM4 工业载板</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">oob-agent 0.9.4</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">PWR</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">RST</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">AC</span> <span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:2px 6px">温度</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">1s</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">HOST-A03</td><td><span class="tag tag-accent">在线</span></td></tr>
|
||||
<tr><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700;color:var(--color-neutral-600)">OOB-06</td><td style="font-size:12.5px;color:var(--color-neutral-600)">RPi 5 + 光耦 ATX 板</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">0.9.4 → 0.9.5</td><td><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px dashed var(--color-neutral-400);padding:2px 6px;color:var(--color-neutral-500)">升级中</span></td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">—</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">HOST-W02</td><td><span style="font-size:10.5px;padding:3px 9px;border:1px solid var(--color-divider);color:var(--color-neutral-600);background:repeating-linear-gradient(135deg,rgba(29,31,32,.10) 0 2px,transparent 2px 5px)">维护</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:var(--color-neutral-600)">带外控制器走独立网络与独立心跳,用于区分主机崩溃与网络故障 · 兼容任意商品 KVM/PDU 作执行器</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ regOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div class="dialog-backdrop" style="z-index:60;animation:omIn .22s ease both">
|
||||
<div class="dialog blueprint elev-lg" style="background:var(--color-bg)">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<span class="dialog-title">登记 DUT</span>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="field"><label>序列号(将与 BDF/设备路径交叉校验)</label><input class="input" placeholder="S6XN…"></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div class="field"><label>型号</label><select class="input"><option>NV-E1T92</option><option>NV-E3T84</option><option>NV-E960</option></select></div>
|
||||
<div class="field"><label>生命周期</label><select class="input"><option>测试专用</option><option>入库检</option><option>只读观察</option></select></div>
|
||||
</div>
|
||||
<div class="field"><label>允许动作</label>
|
||||
<div style="display:flex;gap:14px;font-size:13px;padding-top:2px">
|
||||
<label style="display:flex;gap:6px;align-items:center;cursor:pointer"><input type="checkbox" checked style="accent-color:#5980a6">破坏性写入</label>
|
||||
<label style="display:flex;gap:6px;align-items:center;cursor:pointer"><input type="checkbox" checked style="accent-color:#5980a6">固件刷写</label>
|
||||
<label style="display:flex;gap:6px;align-items:center;cursor:pointer"><input type="checkbox" style="accent-color:#5980a6">格式化/Sanitize</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" onClick="{{ closeReg }}">取消</button>
|
||||
<button class="btn btn-primary" onClick="{{ closeReg }}">登记并生成资产标签</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":820}}">
|
||||
class Component extends DCLogic {
|
||||
state = { tab: 'dut', regOpen: false };
|
||||
renderVals() {
|
||||
const t = this.state.tab;
|
||||
const set = v => () => this.setState({ tab: v });
|
||||
return {
|
||||
tHost: t === 'host', tDut: t === 'dut', tFw: t === 'fw', tTool: t === 'tool', tOob: t === 'oob',
|
||||
setHost: set('host'), setDut: set('dut'), setFw: set('fw'), setTool: set('tool'), setOob: set('oob'),
|
||||
regOpen: this.state.regOpen,
|
||||
openReg: () => this.setState({ regOpen: true }),
|
||||
closeReg: () => this.setState({ regOpen: false })
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
</style>
|
||||
</helmet>
|
||||
<dc-import name="ConsoleNav" active="compare" hint-size="100%,92px"></dc-import>
|
||||
<div data-screen-label="版本比较" style="max-width:1400px;margin:0 auto;padding:22px 24px 64px;font-family:var(--font-body);animation:omIn .5s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.18em;color:var(--color-accent-700);font-weight:500">05 · A/B COMPARE</div>
|
||||
<h2 style="margin:2px 0 0;font-size:30px">版本比较</h2>
|
||||
<div class="text-muted" style="font-size:12.5px;margin-top:2px">fw_ab_powerstate_regression · run-20260725-0003(A) ⇄ run-20260726-0007+复跑(B) · 报告生成于任务结束后 42s</div>
|
||||
</div>
|
||||
<span style="margin-left:auto"></span>
|
||||
<button class="btn btn-secondary" style="font-size:12.5px">导出报告</button>
|
||||
<button class="btn btn-primary" style="font-size:13px">生成缺陷单草稿 · 2</button>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:16px 18px;margin-bottom:18px;border-color:#b03d2e;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#b03d2e" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>
|
||||
<div>
|
||||
<div style="font-family:var(--font-heading);font-weight:600;font-size:20px;letter-spacing:.02em">执行摘要:不建议 FW_3.3.0-rc2 进入下一阶段</div>
|
||||
<div style="font-size:13px;color:var(--color-neutral-800);margin-top:2px">1 个阻断问题(数据完整性) · 2 个新增失败簇 · 4k 随机写性能回退超出已审核阈值</div>
|
||||
</div>
|
||||
<span style="margin-left:auto;font-size:11px;padding:4px 12px;background:#b03d2e;color:#f7f4f2;font-family:var(--font-heading);font-weight:600;letter-spacing:.08em">GATE · 阻断</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;font-family:ui-monospace,Menlo,monospace;font-size:10.5px">
|
||||
<span style="border:1px solid #b03d2e;color:#8a2e22;padding:3px 9px">阻断 FS-0074 · DUT_DATA_FAILURE · LBA 0x2E41_0000–0x2E41_3FFF 校验失败 ×2</span>
|
||||
<a href="Evidence.dc.html" style="border:1px solid var(--color-accent-400);color:var(--color-accent-700);padding:3px 9px;text-decoration:none">证据 run-20260726-0007 →</a>
|
||||
<a href="Failures.dc.html" style="border:1px solid var(--color-accent-400);color:var(--color-accent-700);padding:3px 9px;text-decoration:none">失败簇 FS-0091 →</a>
|
||||
</div>
|
||||
<div style="font-size:11.5px;color:var(--color-neutral-600)">发布决策由工程师作出 — 系统只提供门禁证据与风险提示 · 审阅人:__________ · 日期:2026-07-27</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;align-items:center;border:1px solid var(--color-divider);padding:9px 14px;margin-bottom:18px;font-size:12px;flex-wrap:wrap">
|
||||
<span style="font-size:10px;letter-spacing:.12em;color:var(--color-neutral-600);font-weight:500">环境锁定 ENV LOCK</span>
|
||||
<span style="color:var(--color-accent-700)">✓ 主机 HOST-A01/L02 同批</span>
|
||||
<span style="color:var(--color-accent-700)">✓ BIOS F26b</span>
|
||||
<span style="color:var(--color-accent-700)">✓ OS/驱动一致</span>
|
||||
<span style="color:var(--color-accent-700)">✓ 工具链 nvme-cli 2.9.1 · fio 3.36</span>
|
||||
<span style="color:var(--color-accent-700)">✓ 工作流 v3 同签名</span>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">两次测试可比 · 无环境漂移</span>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:18px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:16px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:700">FW_3.2.1</span>
|
||||
<span class="tag tag-outline">A · 基线</span>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">run-20260725-0003 · WS-04</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:20px">
|
||||
<div><span style="font-family:var(--font-heading);font-weight:600;font-size:52px;line-height:.95">98<span style="font-size:24px">%</span></span><div style="font-size:11px;color:var(--color-neutral-600);margin-top:2px">循环通过率</div></div>
|
||||
<div style="font-size:12px;line-height:1.7;color:var(--color-neutral-800);font-family:ui-monospace,Menlo,monospace">100/100 完成 · 失败 2<br>自动恢复 1 · 人工介入 0</div>
|
||||
</div>
|
||||
<div style="height:14px;border:1px solid var(--color-divider);display:flex">
|
||||
<div style="width:98%;background:var(--color-accent)" title="通过"></div>
|
||||
<div style="width:1%;background:var(--color-accent-200)"></div>
|
||||
<div style="width:1%;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px)"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:10px;color:var(--color-neutral-600)"><span><span style="display:inline-block;width:9px;height:8px;background:var(--color-accent);vertical-align:-1px"></span> 通过 98</span><span><span style="display:inline-block;width:9px;height:8px;background:var(--color-accent-200);vertical-align:-1px"></span> 恢复后通过 1</span><span><span style="display:inline-block;width:9px;height:8px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);vertical-align:-1px"></span> INFRA 1</span></div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:16px;gap:10px;border-color:var(--color-accent-600)">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:700">FW_3.3.0-rc2</span>
|
||||
<span class="tag tag-accent">B · 候选</span>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">run-20260726-0007 + 复跑 · WS-03</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:20px">
|
||||
<div><span style="font-family:var(--font-heading);font-weight:600;font-size:52px;line-height:.95">89<span style="font-size:24px">%</span></span><div style="font-size:11px;color:var(--color-neutral-600);margin-top:2px">循环通过率 <span style="color:#8a2e22">▼ 9pp</span></div></div>
|
||||
<div style="font-size:12px;line-height:1.7;color:var(--color-neutral-800);font-family:ui-monospace,Menlo,monospace">97/100 完成 · 失败 11<br>自动恢复 4 · 人工介入 1</div>
|
||||
</div>
|
||||
<div style="height:14px;border:1px solid var(--color-divider);display:flex">
|
||||
<div style="width:89%;background:var(--color-accent)"></div>
|
||||
<div style="width:4%;background:var(--color-accent-200)"></div>
|
||||
<div style="width:5%;background:var(--color-accent-800)"></div>
|
||||
<div style="width:2%;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px)"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:10px;color:var(--color-neutral-600)"><span><span style="display:inline-block;width:9px;height:8px;background:var(--color-accent);vertical-align:-1px"></span> 通过 89</span><span><span style="display:inline-block;width:9px;height:8px;background:var(--color-accent-200);vertical-align:-1px"></span> 恢复后通过 4</span><span><span style="display:inline-block;width:9px;height:8px;background:var(--color-accent-800);vertical-align:-1px"></span> DUT 失败 5</span><span><span style="display:inline-block;width:9px;height:8px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);vertical-align:-1px"></span> INFRA 2</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 style="color:var(--color-neutral-700);margin:0 0 10px">问题变化 · ISSUE DELTA</h6>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:18px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:8px;border-color:#b03d2e">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><span class="card-kicker" style="color:#8a2e22">新增 NEW</span><span style="margin-left:auto;font-family:var(--font-heading);font-weight:600;font-size:24px;color:#8a2e22">2</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:7px;font-size:11.5px">
|
||||
<div style="border:1px solid var(--color-divider);padding:7px 9px"><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;background:#b03d2e;color:#f7f4f2;padding:1px 6px">阻断</span> <a href="Failures.dc.html">FS-0074</a> 数据校验失败 ×2<div style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;color:var(--color-neutral-500);margin-top:3px">DUT_DATA_FAILURE</div></div>
|
||||
<div style="border:1px solid var(--color-divider);padding:7px 9px"><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;background:var(--color-accent-800);color:#f2f2f3;padding:1px 6px">DUT</span> <a href="Failures.dc.html">FS-0091</a> S4 唤醒掉盘 ×3<div style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;color:var(--color-neutral-500);margin-top:3px">DUT_ENUMERATION_FAILURE</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><span class="card-kicker">已修复 FIXED</span><span style="margin-left:auto;font-family:var(--font-heading);font-weight:600;font-size:24px;color:var(--color-accent-700)">5</span></div>
|
||||
<div style="font-size:11.5px;line-height:1.8;color:var(--color-neutral-800)">FS-0031 冷启动超时<br>FS-0044 SMART 读取挂起<br>FS-0052 Trim 后延迟尖峰<br>FS-0058 唤醒后链路降速<br>FS-0063 日志页 CRC</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><span class="card-kicker">仍存在 PERSIST</span><span style="margin-left:auto;font-family:var(--font-heading);font-weight:600;font-size:24px">3</span></div>
|
||||
<div style="font-size:11.5px;line-height:1.8;color:var(--color-neutral-800)">FS-0067 fio 参数兼容(SCRIPT)<br>FS-0071 高温限速告警<br>FS-0088 主机蓝屏(INFRA)</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><span class="card-kicker">频率/severity 变化</span><span style="margin-left:auto;font-family:var(--font-heading);font-weight:600;font-size:24px">1</span></div>
|
||||
<div style="font-size:11.5px;line-height:1.7;color:var(--color-neutral-800)">FS-0071 高温限速<div style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;margin-top:4px">A:4 次 → B:9 次 <span style="color:#8a2e22">▲ 偶发转频发</span></div><div style="font-size:10.5px;color:var(--color-neutral-600);margin-top:3px">建议纳入观察名单并触发热相关单变量验证</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1.5fr 1fr;gap:18px;align-items:start">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">性能 · 温度 · 恢复</h6>
|
||||
<table class="table">
|
||||
<thead><tr><th>指标</th><th style="text-align:right">FW_3.2.1 A</th><th style="text-align:right">FW_3.3.0-rc2 B</th><th style="text-align:right">Δ</th><th>判定</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="font-size:12.5px">顺序读 MB/s</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">7,012</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">7,048</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--color-accent-700)">+0.5%</td><td><span class="tag tag-outline">PASS</span></td></tr>
|
||||
<tr><td style="font-size:12.5px">顺序写 MB/s</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">6,388</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">6,341</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">-0.7%</td><td><span class="tag tag-outline">PASS</span></td></tr>
|
||||
<tr><td style="font-size:12.5px">4k 随机读 IOPS</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">1,148k</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">1,131k</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">-1.5%</td><td><span class="tag tag-outline">PASS</span></td></tr>
|
||||
<tr style="background:#f7e9e6">
|
||||
<td style="font-size:12.5px;font-weight:600">4k 随机写 IOPS</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">238k</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">218k</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:#8a2e22;font-weight:700">-8.2%</td><td><span style="font-size:10.5px;padding:2px 8px;background:#b03d2e;color:#f7f4f2;font-family:ui-monospace,Menlo,monospace">超阈值 -5%</span></td>
|
||||
</tr>
|
||||
<tr><td style="font-size:12.5px">P99 写延迟 µs</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">377</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">412</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:#8a2e22">+9.3%</td><td><span style="font-size:10.5px;padding:2px 8px;border:1px solid var(--color-divider);color:var(--color-neutral-700);font-family:ui-monospace,Menlo,monospace">观察</span></td></tr>
|
||||
<tr><td style="font-size:12.5px">S4 唤醒枚举耗时 s</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">1.8</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">3.4 / 超时×3</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:#8a2e22">+89%</td><td><span style="font-size:10.5px;padding:2px 8px;background:var(--color-accent-800);color:#f2f2f3;font-family:ui-monospace,Menlo,monospace">关联 FS-0091</span></td></tr>
|
||||
<tr><td style="font-size:12.5px">SMART 温度峰值 °C</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">44.1</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">46.2</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">+2.1</td><td><span class="tag tag-outline">PASS</span></td></tr>
|
||||
<tr><td style="font-size:12.5px">自动恢复次数</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">1</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px">4</td><td style="text-align:right;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:#8a2e22">×4</td><td><span style="font-size:10.5px;padding:2px 8px;border:1px solid var(--color-divider);color:var(--color-neutral-700);font-family:ui-monospace,Menlo,monospace">观察</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:var(--color-neutral-600)">阈值来自已审核工作流 v3 · 全部指标可回链原始 metrics.json 与 fio 输出</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">下一步建议 · NEXT</h6>
|
||||
<div style="display:flex;flex-direction:column;gap:9px;font-size:12.5px;line-height:1.6">
|
||||
<div style="border:1px solid var(--color-divider);padding:9px 11px"><strong style="font-family:var(--font-heading);letter-spacing:.02em">最短复现</strong><div style="color:var(--color-neutral-800);margin-top:3px">fw_ab 复现包:S4 循环 ×20 · 固定 WS-03 + SAMPLE-002 · 预计 3.5h</div><button class="btn btn-ghost" style="font-size:12px;margin-top:4px;padding-inline:0">创建复现任务 →</button></div>
|
||||
<div style="border:1px solid var(--color-divider);padding:9px 11px"><strong style="font-family:var(--font-heading);letter-spacing:.02em">单变量验证</strong><div style="color:var(--color-neutral-800);margin-top:3px">固定固件 B,更换主机 HOST-L01(排除 BIOS F26b 电源时序)</div></div>
|
||||
<div style="border:1px solid var(--color-divider);padding:9px 11px"><strong style="font-family:var(--font-heading);letter-spacing:.02em">建议责任团队</strong><div style="color:var(--color-neutral-800);margin-top:3px">固件电源管理组(FS-0091/0074) · 测试平台组(FS-0067 脚本兼容)</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">缺陷草稿 · ISSUE DRAFT</h6>
|
||||
<div style="border:1px solid var(--color-divider);padding:9px 11px;font-size:12px;line-height:1.6">
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--color-neutral-500)">issue-draft.md · 待审阅</div>
|
||||
<div style="margin-top:4px"><strong>[FW_3.3.0-rc2] S4 唤醒后 PCIe 链路训练失败导致掉盘</strong></div>
|
||||
<div style="color:var(--color-neutral-700);margin-top:3px">复现率 3/100 · 附 Evidence Bundle ×3 · 最短复现包已生成 · 引用 kernel.log / power-actions</div>
|
||||
<div style="display:flex;gap:8px;margin-top:7px"><button class="btn btn-secondary" style="font-size:11.5px;padding:4px 10px">推送 Jira</button><button class="btn btn-secondary" style="font-size:11.5px;padding:4px 10px">推送禅道</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":1080}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
@keyframes omPulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="font-family:var(--font-body);background:var(--color-bg);border-bottom:1px solid var(--color-divider)">
|
||||
<sc-if value="{{ stopped }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="animation:omIn .3s ease both;display:flex;align-items:center;gap:12px;background:#b03d2e;color:#f7f4f2;padding:8px 20px;font-size:13px">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<strong style="font-family:var(--font-heading);letter-spacing:.06em">实验室处于安全停止状态</strong>
|
||||
<span style="opacity:.85">全部任务已冻结,现场证据已保留,自动重启被禁止 · 触发人 quan.zhang · {{ stopTime }}</span>
|
||||
<button onClick="{{ releaseStop }}" style="margin-left:auto;background:transparent;border:1px solid rgba(247,244,242,.55);color:#f7f4f2;padding:4px 12px;font-family:var(--font-heading);font-size:12px;letter-spacing:.05em;cursor:pointer" style-hover="background:rgba(247,244,242,.14)">解除安全停止(需审批)</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="display:flex;align-items:center;gap:18px;padding:0 20px;height:56px">
|
||||
<a href="Dashboard.dc.html" style-hover="color:var(--color-accent-700)" style="transition:color .15s ease;flex:none;white-space:nowrap;text-decoration:none;color:var(--color-text);display:flex;flex-direction:column;gap:0;line-height:1">
|
||||
<span style="font-family:var(--font-heading);font-weight:600;font-size:19px;letter-spacing:.08em">STORAGE LABOS</span>
|
||||
<span style="font-size:9px;letter-spacing:.22em;color:var(--color-accent-700);margin-top:3px">REGRESSION WORKER · 多工位控制台</span>
|
||||
</a>
|
||||
<span style="flex:none;width:1px;height:26px;background:var(--color-divider)"></span>
|
||||
<nav style="display:flex;align-items:stretch;gap:2px;height:100%">
|
||||
<sc-for list="{{ items }}" as="it" hint-placeholder-count="8">
|
||||
<a href="{{ it.href }}" aria-current="{{ it.cur }}" style="{{ it.style }}" style-hover="background:var(--color-accent-100);color:var(--color-accent-700)">
|
||||
<span style="font-size:13.5px;line-height:1.1">{{ it.label }}</span>
|
||||
<span style="font-size:8px;letter-spacing:.14em;opacity:.55;margin-top:2px">{{ it.en }}</span>
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
<span style="margin-left:auto"></span>
|
||||
<span class="tag tag-neutral" style="flex:none;white-space:nowrap;gap:6px"><span style="width:6px;height:6px;background:var(--color-accent);display:inline-block"></span>内网部署 · 深圳一号实验室</span>
|
||||
<span style="flex:none;white-space:nowrap;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;color:var(--color-neutral-700);min-width:62px">{{ clock }}</span>
|
||||
<button onClick="{{ openStop }}" style="flex:none;white-space:nowrap;display:inline-flex;align-items:center;gap:7px;background:#b03d2e;border:1px solid #8a2e22;color:#f7f4f2;padding:8px 14px;font-family:var(--font-heading);font-weight:600;font-size:13px;letter-spacing:.08em;cursor:pointer" style-hover="background:#9a3527">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon></svg>
|
||||
紧急停止
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:16px;padding:5px 20px;border-top:1px solid var(--color-divider);background:var(--color-surface);font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;color:var(--color-neutral-700);letter-spacing:.02em">
|
||||
<span style="display:inline-flex;align-items:center;gap:5px;color:var(--color-accent-700)"><span style="width:6px;height:6px;border-radius:50%;background:var(--color-accent);animation:omPulse 2s infinite"></span>事件总线 LIVE</span>
|
||||
<span>工位 5/6 在线</span>
|
||||
<a href="LiveRun.dc.html" style="color:var(--color-accent-800);text-decoration:none" style-hover="color:var(--color-accent)">运行 2</a>
|
||||
<span>恢复中 1</span>
|
||||
<a href="Tasks.dc.html" style="color:inherit;text-decoration:none" style-hover="color:var(--color-accent-700)">队列 3</a>
|
||||
<span style="width:1px;height:12px;background:var(--color-divider)"></span>
|
||||
<span>UCR(7D) 96.8%</span>
|
||||
<span>介入密度 1.8 次/百小时</span>
|
||||
<span>自动恢复成功率 87.5%</span>
|
||||
<span style="margin-left:auto">最近人工介入 14:32 · WS-03</span>
|
||||
</div>
|
||||
<sc-if value="{{ stopOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div class="dialog-backdrop" style="z-index:60;animation:omIn .22s ease both">
|
||||
<div class="dialog blueprint elev-lg" style="width:min(520px,100%);background:var(--color-bg)">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:10px;color:#b03d2e">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span class="dialog-title" style="letter-spacing:.04em">紧急停止 · EMERGENCY STOP</span>
|
||||
</div>
|
||||
<div class="dialog-body" style="opacity:1">
|
||||
<p style="margin-bottom:8px">将对<strong>全部 6 个工位</strong>执行安全停止,该动作写入不可变审计日志:</p>
|
||||
<ul style="margin:0;padding-left:18px;font-size:13.5px;line-height:1.7;color:var(--color-neutral-800)">
|
||||
<li>停止 2 个运行中任务(run-20260727-0001 / 0002),在当前检查点冻结</li>
|
||||
<li>中断 WS-03 正在执行的 L3 带外 Reset 恢复流程</li>
|
||||
<li>保留全部现场证据,禁止任何自动重启与供电动作</li>
|
||||
</ul>
|
||||
<div style="margin-top:12px;padding:9px 12px;border:1px dashed #b03d2e;color:#8a2e22;font-size:12.5px;background:#f7e9e6">恢复运行需要实验室负责人审批。误触发将计入基础设施误报率。</div>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" onClick="{{ closeStop }}">取消</button>
|
||||
<button onClick="{{ confirmStop }}" style="display:inline-flex;align-items:center;gap:7px;background:#b03d2e;border:1px solid #8a2e22;color:#f7f4f2;padding:8px 16px;font-family:var(--font-heading);font-weight:600;font-size:14px;letter-spacing:.06em;cursor:pointer" style-hover="background:#9a3527">确认全部停止</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":120},"active":{"editor":"enum","options":["dashboard","tasks","live","evidence","compare","failures","assets","workflows"],"default":"dashboard","tsType":"string","section":"导航"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { stopOpen: false, stopped: false, stopTime: '', clock: '' };
|
||||
componentDidMount() {
|
||||
this._t = setInterval(() => {
|
||||
this.setState({ clock: new Date().toLocaleTimeString('zh-CN', { hour12: false }) });
|
||||
}, 1000);
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this._t); }
|
||||
renderVals() {
|
||||
const active = this.props.active ?? 'dashboard';
|
||||
const base = {
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flex: 'none', whiteSpace: 'nowrap',
|
||||
padding: '0 11px', textDecoration: 'none', color: 'var(--color-neutral-800)',
|
||||
fontFamily: 'var(--font-heading)', fontWeight: 600, letterSpacing: '.03em',
|
||||
borderBottom: '2px solid transparent', borderTop: '2px solid transparent',
|
||||
transition: 'color .15s ease, background .15s ease, border-color .15s ease'
|
||||
};
|
||||
const items = [
|
||||
{ key: 'dashboard', href: 'Dashboard.dc.html', label: '总览', en: 'OVERVIEW' },
|
||||
{ key: 'tasks', href: 'Tasks.dc.html', label: '任务中心', en: 'TASKS' },
|
||||
{ key: 'live', href: 'LiveRun.dc.html', label: '实时运行', en: 'LIVE RUN' },
|
||||
{ key: 'evidence', href: 'Evidence.dc.html', label: '证据浏览器', en: 'EVIDENCE' },
|
||||
{ key: 'compare', href: 'Compare.dc.html', label: '版本比较', en: 'A/B COMPARE' },
|
||||
{ key: 'failures', href: 'Failures.dc.html', label: '失败中心', en: 'FAILURES' },
|
||||
{ key: 'assets', href: 'Assets.dc.html', label: '资产中心', en: 'ASSETS' },
|
||||
{ key: 'workflows', href: 'Workflows.dc.html', label: '工作流模板', en: 'WORKFLOWS' }
|
||||
].map(it => ({
|
||||
...it,
|
||||
cur: it.key === active ? 'page' : null,
|
||||
style: it.key === active
|
||||
? { ...base, color: 'var(--color-accent-700)', borderBottom: '2px solid var(--color-accent)', background: 'var(--color-accent-100)' }
|
||||
: base
|
||||
}));
|
||||
return {
|
||||
items,
|
||||
clock: this.state.clock,
|
||||
stopOpen: this.state.stopOpen,
|
||||
stopped: this.state.stopped,
|
||||
stopTime: this.state.stopTime,
|
||||
openStop: () => this.setState({ stopOpen: true }),
|
||||
closeStop: () => this.setState({ stopOpen: false }),
|
||||
confirmStop: () => this.setState({ stopOpen: false, stopped: true, stopTime: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }),
|
||||
releaseStop: () => this.setState({ stopped: false })
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,429 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
@keyframes omPulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||
</style>
|
||||
</helmet>
|
||||
<dc-import name="ConsoleNav" active="dashboard" hint-size="100%,92px"></dc-import>
|
||||
<div data-screen-label="总览 Dashboard" style="max-width:1400px;margin:0 auto;padding:22px 24px 64px;font-family:var(--font-body);animation:omIn .5s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;margin-bottom:20px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.18em;color:var(--color-accent-700);font-weight:500">01 · OVERVIEW</div>
|
||||
<h2 style="margin:2px 0 0;font-size:30px">总览</h2>
|
||||
<div class="text-muted" style="font-size:12.5px;margin-top:2px">深圳一号实验室 · 6 工位 · 遥测每 2s 刷新 · 2026-07-27</div>
|
||||
</div>
|
||||
<span style="margin-left:auto"></span>
|
||||
<div class="seg" style="background:var(--color-bg)">
|
||||
<label class="seg-opt"><input type="radio" name="dashv" checked="{{ vA }}" onChange="{{ setA }}"><span>A · 工位网格</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="dashv" checked="{{ vB }}" onChange="{{ setB }}"><span>B · 指标墙</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="dashv" checked="{{ vC }}" onChange="{{ setC }}"><span>C · 作战板</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ vA }}" hint-placeholder-val="{{ true }}">
|
||||
<div data-screen-label="总览 · 变体A 工位网格" style="animation:omIn .35s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:22px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:6px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<span class="card-kicker">UCR · 无人值守完成率(7D)</span>
|
||||
<div style="display:flex;align-items:flex-end;gap:10px">
|
||||
<span style="font-family:var(--font-heading);font-weight:600;font-size:44px;line-height:.95">96.8<span style="font-size:22px">%</span></span>
|
||||
<svg width="60" height="18" viewBox="0 0 60 18" style="margin-bottom:4px"><polyline stroke-linejoin="round" stroke-linecap="round" points="0,10 10,8 20,12 30,5 40,6 50,2 60,7" fill="none" stroke="var(--color-accent)" stroke-width="1.5" style="stroke-dasharray:90 90;stroke-dashoffset:90;animation:omDraw 1s ease .2s both"></polyline><circle cx="60" cy="7" r="2.5" fill="var(--color-accent)" style="animation:omPulse 2.4s infinite"></circle></svg>
|
||||
</div>
|
||||
<span style="font-size:11.5px;color:var(--color-accent-700)">▲ 0.6pp vs 上周 · 目标 98%</span>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:6px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<span class="card-kicker">释放工时 · HOURS FREED</span>
|
||||
<div style="display:flex;align-items:flex-end;gap:10px">
|
||||
<span style="font-family:var(--font-heading);font-weight:600;font-size:44px;line-height:.95">41.5<span style="font-size:20px"> h/周</span></span>
|
||||
<svg width="60" height="18" viewBox="0 0 60 18" style="margin-bottom:4px"><polyline stroke-linejoin="round" stroke-linecap="round" points="0,14 10,12 20,11 30,9 40,8 50,6 60,4" fill="none" stroke="var(--color-accent)" stroke-width="1.5" style="stroke-dasharray:90 90;stroke-dashoffset:90;animation:omDraw 1s ease .2s both"></polyline><circle cx="60" cy="4" r="2.5" fill="var(--color-accent)" style="animation:omPulse 2.4s infinite"></circle></svg>
|
||||
</div>
|
||||
<span style="font-size:11.5px;color:var(--color-neutral-700)">≈ 0.9 工程师 · ¥32万/年等值</span>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:6px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<span class="card-kicker">介入密度 · TOUCHES /100H · 主指标</span>
|
||||
<div style="display:flex;align-items:flex-end;gap:10px">
|
||||
<span style="font-family:var(--font-heading);font-weight:600;font-size:44px;line-height:.95">1.8</span>
|
||||
<svg width="60" height="18" viewBox="0 0 60 18" style="margin-bottom:4px"><polyline stroke-linejoin="round" stroke-linecap="round" points="0,4 10,6 20,5 30,9 40,10 50,12 60,13" fill="none" stroke="var(--color-accent)" stroke-width="1.5" style="stroke-dasharray:90 90;stroke-dashoffset:90;animation:omDraw 1s ease .2s both"></polyline><circle cx="60" cy="13" r="2.5" fill="var(--color-accent)" style="animation:omPulse 2.4s infinite"></circle></svg>
|
||||
</div>
|
||||
<span style="font-size:11.5px;color:var(--color-accent-700)">▼ 0.4 vs 上周 · 口径:任何人工触碰</span>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:6px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<span class="card-kicker">自动恢复成功率 · RECOVERY</span>
|
||||
<div style="display:flex;align-items:flex-end;gap:10px">
|
||||
<span style="font-family:var(--font-heading);font-weight:600;font-size:44px;line-height:.95">87.5<span style="font-size:22px">%</span></span>
|
||||
<svg width="60" height="18" viewBox="0 0 60 18" style="margin-bottom:4px"><polyline stroke-linejoin="round" stroke-linecap="round" points="0,12 10,9 20,11 30,7 40,8 50,5 60,6" fill="none" stroke="var(--color-accent)" stroke-width="1.5" style="stroke-dasharray:90 90;stroke-dashoffset:90;animation:omDraw 1s ease .2s both"></polyline><circle cx="60" cy="6" r="2.5" fill="var(--color-accent)" style="animation:omPulse 2.4s infinite"></circle></svg>
|
||||
</div>
|
||||
<span style="font-size:11.5px;color:var(--color-neutral-700)">28/32 · L1 62% · L2 21% · L3 17%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 340px;gap:22px;align-items:start">
|
||||
<div>
|
||||
<h6 style="color:var(--color-neutral-700);margin-bottom:12px">工位状态 · WORKSTATIONS 6</h6>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px">
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;font-weight:700">WS-01</span>
|
||||
<span class="text-muted" style="font-size:11.5px">HOST-A01 · Win11 22631</span>
|
||||
<span style="margin-left:auto;display:inline-flex;align-items:center;gap:6px;background:var(--color-accent);color:#f2f2f3;font-size:10.5px;letter-spacing:.06em;padding:3px 9px"><span style="width:5px;height:5px;border-radius:50%;background:#f2f2f3;animation:omPulse 1.6s infinite"></span>RUNNING</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-family:var(--font-heading);font-weight:600;font-size:16.5px">fw_ab_powerstate_regression</div>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-neutral-600)"><a href="LiveRun.dc.html" style="text-decoration:none">run-20260727-0001</a> · FW_3.3.0-rc2(B) · 休眠恢复循环</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--color-neutral-700);margin-bottom:4px"><span>轮次 67 / 100</span><span>预计完成 23:40</span></div>
|
||||
<div style="height:6px;border:1px solid var(--color-divider);position:relative"><div style="position:absolute;inset:0;width:67%;background-color:var(--color-accent);background-image:repeating-linear-gradient(90deg,rgba(242,242,243,.3) 0 6px,transparent 6px 14px);animation:omScan 1.1s linear infinite"></div><div style="position:absolute;top:-2px;bottom:-2px;left:25%;width:1px;background:var(--color-divider)"></div><div style="position:absolute;top:-2px;bottom:-2px;left:50%;width:1px;background:var(--color-divider)"></div><div style="position:absolute;top:-2px;bottom:-2px;left:75%;width:1px;background:var(--color-divider)"></div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:11px;color:var(--color-neutral-700);font-family:ui-monospace,Menlo,monospace"><span>DUT SAMPLE-001</span><span>{{ t01 }}°C</span><span style="color:var(--color-accent-700)">心跳 {{ hb01 }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;font-weight:700">WS-02</span>
|
||||
<span class="text-muted" style="font-size:11.5px">HOST-A02 · Win11 22631</span>
|
||||
<span style="margin-left:auto;display:inline-flex;align-items:center;gap:6px;background:var(--color-accent);color:#f2f2f3;font-size:10.5px;letter-spacing:.06em;padding:3px 9px"><span style="width:5px;height:5px;border-radius:50%;background:#f2f2f3;animation:omPulse 1.6s infinite"></span>RUNNING</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-family:var(--font-heading);font-weight:600;font-size:16.5px">fw_retention_72h</div>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-neutral-600)">run-20260727-0002 · FW_3.2.1(A) · 高温数据保持</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--color-neutral-700);margin-bottom:4px"><span>51.2 / 72 h</span><span>预计完成 07-29 09:10</span></div>
|
||||
<div style="height:6px;border:1px solid var(--color-divider);position:relative"><div style="position:absolute;inset:0;width:71%;background-color:var(--color-accent);background-image:repeating-linear-gradient(90deg,rgba(242,242,243,.3) 0 6px,transparent 6px 14px);animation:omScan 1.1s linear infinite"></div><div style="position:absolute;top:-2px;bottom:-2px;left:25%;width:1px;background:var(--color-divider)"></div><div style="position:absolute;top:-2px;bottom:-2px;left:50%;width:1px;background:var(--color-divider)"></div><div style="position:absolute;top:-2px;bottom:-2px;left:75%;width:1px;background:var(--color-divider)"></div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:11px;color:var(--color-neutral-700);font-family:ui-monospace,Menlo,monospace"><span>DUT SAMPLE-004</span><span>{{ t02 }}°C</span><span style="color:var(--color-accent-700)">心跳 {{ hb02 }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px;border-color:var(--color-accent-600)">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;font-weight:700">WS-03</span>
|
||||
<span class="text-muted" style="font-size:11.5px">HOST-L02 · Ubuntu 22.04</span>
|
||||
<span style="margin-left:auto;display:inline-flex;align-items:center;gap:6px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10.5px;letter-spacing:.06em;padding:3px 9px;animation:omPulse 1.2s infinite">RECOVERING · L3</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-family:var(--font-heading);font-weight:600;font-size:16.5px">fw_ab_powerstate_regression</div>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-neutral-600)"><a href="Evidence.dc.html" style="text-decoration:none">run-20260726-0007</a> · FW_3.3.0-rc2(B) · 轮次 41/100 冻结</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:10.5px;font-family:ui-monospace,Menlo,monospace">
|
||||
<span style="padding:2px 7px;border:1px solid var(--color-divider);color:var(--color-neutral-500);text-decoration:line-through">L1 软恢复</span>
|
||||
<span style="color:var(--color-neutral-400)">→</span>
|
||||
<span style="padding:2px 7px;border:1px solid var(--color-divider);color:var(--color-neutral-500);text-decoration:line-through">L2 OS重启</span>
|
||||
<span style="color:var(--color-neutral-400)">→</span>
|
||||
<span style="padding:2px 7px;background:var(--color-accent-800);color:#f2f2f3;animation:omPulse 1.2s infinite">L3 带外RESET</span>
|
||||
<span style="color:var(--color-neutral-400)">→</span>
|
||||
<span style="padding:2px 7px;border:1px dashed var(--color-divider);color:var(--color-neutral-500)">L4 AC断电</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:11px;color:var(--color-neutral-700);font-family:ui-monospace,Menlo,monospace"><span>DUT SAMPLE-002</span><span>43.8°C</span><span style="color:#8a2e22">心跳丢失 {{ lost03 }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;font-weight:700">WS-04</span>
|
||||
<span class="text-muted" style="font-size:11.5px">HOST-L01 · Ubuntu 22.04</span>
|
||||
<span class="tag tag-outline" style="margin-left:auto;font-size:10.5px;letter-spacing:.06em">IDLE</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:13px;line-height:1.6">空闲,可接受调度。<br>上一任务 <a href="Compare.dc.html" style="text-decoration:none;font-family:ui-monospace,Menlo,monospace;font-size:11.5px">run-20260725-0003</a> · PASSED · 100/100 循环</div>
|
||||
<div style="display:flex;gap:14px;font-size:11px;color:var(--color-neutral-700);font-family:ui-monospace,Menlo,monospace"><span>DUT 未绑定</span><span>36.2°C</span><span style="color:var(--color-accent-700)">心跳 {{ hb04 }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;font-weight:700">WS-05</span>
|
||||
<span class="text-muted" style="font-size:11.5px">HOST-A03 · Win11 22631</span>
|
||||
<span class="tag tag-accent" style="margin-left:auto;font-size:10.5px;letter-spacing:.06em">QUEUED</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-family:var(--font-heading);font-weight:600;font-size:16.5px">fw_coldboot_matrix</div>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-neutral-600)">预约 今日 22:00 · 500 次冷启动 · FW_3.3.0-rc2(B)</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:11px;color:var(--color-neutral-700);font-family:ui-monospace,Menlo,monospace"><span>DUT SAMPLE-006</span><span>35.9°C</span><span style="color:var(--color-accent-700)">心跳 {{ hb05 }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.05) 0 2px,transparent 2px 7px)">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;font-weight:700;color:var(--color-neutral-600)">WS-06</span>
|
||||
<span class="text-muted" style="font-size:11.5px">HOST-W02 · Win10 19045</span>
|
||||
<span style="margin-left:auto;font-size:10.5px;letter-spacing:.06em;padding:3px 9px;border:1px solid var(--color-divider);color:var(--color-neutral-600);background:repeating-linear-gradient(135deg,rgba(29,31,32,.12) 0 2px,transparent 2px 5px)">OFFLINE</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:13px;line-height:1.6">维护窗口至 07-28 09:00<br>带外控制器 OOB-06 固件升级中</div>
|
||||
<div style="display:flex;gap:14px;font-size:11px;color:var(--color-neutral-500);font-family:ui-monospace,Menlo,monospace"><span>DUT 已下线</span><span>—</span><span>心跳 —</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:18px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">需要人工处理</h6>
|
||||
<span style="margin-left:auto;font-family:var(--font-heading);font-weight:600;font-size:18px;color:#8a2e22">3</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:9px">
|
||||
<div style="transition:background .15s ease,border-color .15s ease;display:flex;gap:9px;align-items:flex-start;border:1px solid var(--color-divider);padding:9px 10px" style-hover="background:var(--color-accent-100);border-color:var(--color-accent-400)">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 6px;border:1px dashed var(--color-neutral-500);color:var(--color-neutral-700);white-space:nowrap">UNKNOWN</span>
|
||||
<div style="font-size:12.5px;line-height:1.5">run-20260726-0011 证据不足待分类<div><a href="Failures.dc.html" style="font-size:11.5px">FS-0102 去分类 →</a></div></div>
|
||||
</div>
|
||||
<div style="transition:background .15s ease,border-color .15s ease;display:flex;gap:9px;align-items:flex-start;border:1px solid var(--color-divider);padding:9px 10px" style-hover="background:var(--color-accent-100);border-color:var(--color-accent-400)">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 6px;background:#b03d2e;color:#f7f4f2;white-space:nowrap">阻断</span>
|
||||
<div style="font-size:12.5px;line-height:1.5">FW_3.3.0-rc2 存在阻断问题,需研发确认<div><a href="Compare.dc.html" style="font-size:11.5px">FS-0074 查看对比 →</a></div></div>
|
||||
</div>
|
||||
<div style="transition:background .15s ease,border-color .15s ease;display:flex;gap:9px;align-items:flex-start;border:1px solid var(--color-divider);padding:9px 10px" style-hover="background:var(--color-accent-100);border-color:var(--color-accent-400)">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 6px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.12) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);color:var(--color-neutral-800);white-space:nowrap">维护</span>
|
||||
<div style="font-size:12.5px;line-height:1.5">WS-06 维护窗口今日到期,确认恢复排产<div><a href="Assets.dc.html" style="font-size:11.5px">资产中心 →</a></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">最近恢复动作</h6>
|
||||
<div style="display:flex;flex-direction:column;font-family:ui-monospace,Menlo,monospace;font-size:11px">
|
||||
<div style="display:flex;gap:8px;padding:7px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:35</span><span>WS-03 L3 带外 Reset 已触发</span><span style="margin-left:auto;color:var(--color-accent-700);animation:omPulse 1.4s infinite">进行中</span></div>
|
||||
<div style="display:flex;gap:8px;padding:7px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:33</span><span>WS-03 L2 OS 重启超时(120s)</span><span style="margin-left:auto;color:#8a2e22">失败</span></div>
|
||||
<div style="display:flex;gap:8px;padding:7px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:32</span><span>WS-03 L1 Agent 软恢复无响应</span><span style="margin-left:auto;color:#8a2e22">失败</span></div>
|
||||
<div style="display:flex;gap:8px;padding:7px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">02:31</span><span>WS-03 心跳丢失 → L3 Reset · 4m12s</span><span style="margin-left:auto;color:var(--color-accent-700)">成功</span></div>
|
||||
<div style="display:flex;gap:8px;padding:7px 0"><span style="color:var(--color-neutral-500)">昨 23:04</span><span>WS-01 L1 进程清理 · 38s</span><span style="margin-left:auto;color:var(--color-accent-700)">成功</span></div>
|
||||
</div>
|
||||
<a href="Evidence.dc.html" style="font-size:11.5px">全部恢复记录 →</a>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">今日队列</h6>
|
||||
<div style="display:flex;flex-direction:column;font-size:12.5px">
|
||||
<div style="display:flex;gap:8px;padding:7px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="font-family:ui-monospace,Menlo,monospace;color:var(--color-neutral-500)">22:00</span><span>fw_coldboot_matrix · WS-05</span><span class="tag tag-accent" style="margin-left:auto">P1</span></div>
|
||||
<div style="display:flex;gap:8px;padding:7px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="font-family:ui-monospace,Menlo,monospace;color:var(--color-neutral-500)">23:45</span><span>fw_ab 续跑 cycle 41 · WS-03</span><span class="tag tag-neutral" style="margin-left:auto">P2</span></div>
|
||||
<div style="display:flex;gap:8px;padding:7px 0"><span style="font-family:ui-monospace,Menlo,monospace;color:var(--color-neutral-500)">明 06:00</span><span>incoming_qc_quick · WS-04</span><span class="tag tag-neutral" style="margin-left:auto">P3</span></div>
|
||||
</div>
|
||||
<a href="Tasks.dc.html" style="font-size:11.5px">任务中心 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ vB }}" hint-placeholder-val="{{ false }}">
|
||||
<div data-screen-label="总览 · 变体B 指标墙" style="animation:omIn .35s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:20px 24px;margin-bottom:20px;flex-direction:row;gap:36px;align-items:stretch">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="min-width:250px;display:flex;flex-direction:column;justify-content:center">
|
||||
<span class="card-kicker">北极星 · NORTH STAR</span>
|
||||
<div style="font-family:var(--font-heading);font-weight:600;font-size:84px;line-height:.95;margin:6px 0 4px">96.8<span style="font-size:36px">%</span></div>
|
||||
<div style="font-size:14px;color:var(--color-neutral-800)">无人值守完成率 UCR · 近 7 天</div>
|
||||
<div style="font-size:12px;color:var(--color-accent-700);margin-top:6px">▲ 0.6pp vs 上周 · 产品化目标 98%+</div>
|
||||
</div>
|
||||
<div style="flex:1;border-left:1px solid var(--color-divider);padding-left:32px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--color-neutral-600);margin-bottom:6px"><span>UCR 趋势 · 07-21 → 07-27</span><span style="font-family:ui-monospace,Menlo,monospace">计划任务 214 · 无人完成 207</span></div>
|
||||
<svg width="100%" height="170" viewBox="0 0 700 170" preserveAspectRatio="none" style="display:block">
|
||||
<line x1="40" y1="150" x2="660" y2="150" stroke="rgba(29,31,32,.16)" stroke-width="1"></line>
|
||||
<line x1="40" y1="90" x2="660" y2="90" stroke="rgba(29,31,32,.10)" stroke-width="1"></line>
|
||||
<line x1="40" y1="30" x2="660" y2="30" stroke="rgba(29,31,32,.10)" stroke-width="1"></line>
|
||||
<text x="32" y="153" font-size="9" fill="#7a7a7d" text-anchor="end" font-family="Menlo,monospace">94</text>
|
||||
<text x="32" y="93" font-size="9" fill="#7a7a7d" text-anchor="end" font-family="Menlo,monospace">96</text>
|
||||
<text x="32" y="33" font-size="9" fill="#7a7a7d" text-anchor="end" font-family="Menlo,monospace">98</text>
|
||||
<path d="M40,117 L143,78 L246,126 L349,54 L452,63 L555,27 L658,66 L658,150 L40,150 Z" fill="rgba(89,128,166,.13)"></path>
|
||||
<polyline stroke-linejoin="round" stroke-linecap="round" points="40,117 143,78 246,126 349,54 452,63 555,27 658,66" fill="none" stroke="#5980a6" stroke-width="1.8" style="stroke-dasharray:700 700;stroke-dashoffset:700;animation:omDraw 1.2s ease .15s both"></polyline>
|
||||
<g fill="#f2f2f3" stroke="#416180" stroke-width="1.5"><circle cx="40" cy="117" r="3"></circle><circle cx="143" cy="78" r="3"></circle><circle cx="246" cy="126" r="3"></circle><circle cx="349" cy="54" r="3"></circle><circle cx="452" cy="63" r="3"></circle><circle cx="555" cy="27" r="3"></circle><circle cx="658" cy="66" r="3.5" style="animation:omPulse 2.4s infinite"></circle></g>
|
||||
<text x="40" y="166" font-size="9" fill="#7a7a7d" text-anchor="middle" font-family="Menlo,monospace">07-21</text>
|
||||
<text x="246" y="166" font-size="9" fill="#7a7a7d" text-anchor="middle" font-family="Menlo,monospace">07-23</text>
|
||||
<text x="452" y="166" font-size="9" fill="#7a7a7d" text-anchor="middle" font-family="Menlo,monospace">07-25</text>
|
||||
<text x="658" y="166" font-size="9" fill="#7a7a7d" text-anchor="middle" font-family="Menlo,monospace">07-27</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:16px;margin-bottom:20px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px 14px;gap:4px"><i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i><span class="card-kicker">释放工时</span><span style="font-family:var(--font-heading);font-weight:600;font-size:32px;line-height:1">41.5 h/周</span><span style="font-size:11px;color:var(--color-neutral-700)">≈ 0.9 工程师 · ¥32万/年</span></div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px 14px;gap:4px"><i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i><span class="card-kicker">介入密度</span><span style="font-family:var(--font-heading);font-weight:600;font-size:32px;line-height:1">1.8 /百h</span><span style="font-size:11px;color:var(--color-accent-700)">主指标 · 任何触碰计 1 次</span></div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px 14px;gap:4px"><i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i><span class="card-kicker">自动恢复成功率</span><span style="font-family:var(--font-heading);font-weight:600;font-size:32px;line-height:1">87.5%</span><span style="font-size:11px;color:var(--color-neutral-700)">28/32 次失联</span></div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px 14px;gap:4px"><i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i><span class="card-kicker">证据完整率</span><span style="font-family:var(--font-heading);font-weight:600;font-size:32px;line-height:1">100%</span><span style="font-size:11px;color:var(--color-neutral-700)">关键字段零丢失</span></div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px 14px;gap:4px"><i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i><span class="card-kicker">基础设施误报率</span><span style="font-family:var(--font-heading);font-weight:600;font-size:32px;line-height:1">3.1%</span><span style="font-size:11px;color:var(--color-accent-700)">▼ 护栏指标,单独监控</span></div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:12px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">版本风险 · FIRMWARE GATE</h6>
|
||||
<div style="border:1px solid var(--color-divider);padding:12px">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;font-weight:700">FW_3.3.0-rc2</span>
|
||||
<span class="text-muted" style="font-size:11px">候选(B)</span>
|
||||
<span style="margin-left:auto;font-size:10.5px;padding:3px 9px;background:#b03d2e;color:#f7f4f2;letter-spacing:.06em">暂缓发布</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;font-size:11px;font-family:ui-monospace,Menlo,monospace">
|
||||
<span style="padding:3px 8px;background:#b03d2e;color:#f7f4f2">阻断 1</span>
|
||||
<span style="padding:3px 8px;background:var(--color-accent-800);color:#f2f2f3">新增 2</span>
|
||||
<span style="padding:3px 8px;border:1px solid var(--color-accent-400);color:var(--color-accent-700)">已修复 5</span>
|
||||
<span style="padding:3px 8px;border:1px solid var(--color-divider);color:var(--color-neutral-700)">仍存在 3</span>
|
||||
</div>
|
||||
<div style="font-size:12px;margin-top:10px;color:var(--color-neutral-800)">阻断:FS-0074 数据校验失败(LBA 0x2E41xxxx) · <a href="Compare.dc.html" style="font-size:11.5px">查看 A/B 对比 →</a></div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--color-divider);padding:12px;display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;font-weight:700">FW_3.2.1</span>
|
||||
<span class="text-muted" style="font-size:11px">基线(A)</span>
|
||||
<span class="tag tag-outline" style="margin-left:auto">稳定基线</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">介入原因分布 · 30D</h6>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px">
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>DUT_ENUMERATION_FAILURE</span><span>8</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:100%;background:var(--color-accent-800)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>TEST_SCRIPT_FAILURE</span><span>7</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:87%;background:var(--color-neutral-400)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>INFRA_NETWORK_FAILURE</span><span>6</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:75%;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>INFRA_HOST_OS_FAILURE</span><span>5</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:62%;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>INFRA_AGENT_FAILURE</span><span>4</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:50%;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>UNKNOWN</span><span>2</span></div><div style="height:8px;border:1px dashed var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:25%;background:var(--color-neutral-200)"></div></div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;font-size:10px;color:var(--color-neutral-600);border-top:1px solid var(--color-divider);padding-top:8px"><span><span style="display:inline-block;width:10px;height:8px;background:var(--color-accent-800);vertical-align:-1px"></span> DUT 信号</span><span><span style="display:inline-block;width:10px;height:8px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);vertical-align:-1px"></span> INFRA 噪声</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">工位利用率 · 7D</h6>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px">
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>WS-01</span><span>92%</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:92%;background:var(--color-accent)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>WS-02</span><span>88%</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:88%;background:var(--color-accent)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>WS-03</span><span>61%</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:61%;background:var(--color-accent)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>WS-04</span><span>34%</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:34%;background:var(--color-accent)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>WS-05</span><span>12%</span></div><div style="height:8px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:12%;background:var(--color-accent)"></div></div></div>
|
||||
<div><div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>WS-06</span><span>0% 维护</span></div><div style="height:8px;border:1px solid var(--color-divider);background:repeating-linear-gradient(135deg,rgba(29,31,32,.10) 0 2px,transparent 2px 5px)"></div></div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--color-neutral-700);border-top:1px solid var(--color-divider);padding-top:8px">夜间(22:00–08:00)利用率 74% · 无人值守时段占比 63%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ vC }}" hint-placeholder-val="{{ false }}">
|
||||
<div data-screen-label="总览 · 变体C 作战板" style="animation:omIn .35s cubic-bezier(.2,.7,.2,1) both;display:grid;grid-template-columns:216px 1fr 320px;gap:18px;align-items:start">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:0">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0 0 8px;color:var(--color-neutral-700)">工位 RAIL</h6>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:8px 2px;border-bottom:1px solid rgba(29,31,32,.08)"><span style="width:7px;height:7px;border-radius:50%;background:var(--color-accent);animation:omPulse 1.6s infinite"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700">WS-01</span><span style="font-size:10.5px;color:var(--color-neutral-600)">cycle 67/100</span><span style="margin-left:auto;font-size:9.5px;color:var(--color-accent-700)">RUN</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:8px 2px;border-bottom:1px solid rgba(29,31,32,.08)"><span style="width:7px;height:7px;border-radius:50%;background:var(--color-accent);animation:omPulse 1.6s infinite"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700">WS-02</span><span style="font-size:10.5px;color:var(--color-neutral-600)">51.2/72h</span><span style="margin-left:auto;font-size:9.5px;color:var(--color-accent-700)">RUN</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:8px 2px;border-bottom:1px solid rgba(29,31,32,.08)"><span style="width:7px;height:7px;border-radius:50%;border:1.5px solid var(--color-accent-700);background:var(--color-accent-200);animation:omPulse 1s infinite"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700">WS-03</span><span style="font-size:10.5px;color:var(--color-neutral-600)">L3 RESET</span><span style="margin-left:auto;font-size:9.5px;color:var(--color-accent-800)">RCVR</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:8px 2px;border-bottom:1px solid rgba(29,31,32,.08)"><span style="width:7px;height:7px;border-radius:50%;border:1px solid var(--color-neutral-500)"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700">WS-04</span><span style="font-size:10.5px;color:var(--color-neutral-600)">空闲</span><span style="margin-left:auto;font-size:9.5px;color:var(--color-neutral-600)">IDLE</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:8px 2px;border-bottom:1px solid rgba(29,31,32,.08)"><span style="width:7px;height:7px;border-radius:50%;border:1px solid var(--color-accent-600);background:var(--color-accent-100)"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700">WS-05</span><span style="font-size:10.5px;color:var(--color-neutral-600)">22:00 预约</span><span style="margin-left:auto;font-size:9.5px;color:var(--color-accent-700)">QUE</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:8px 2px"><span style="width:7px;height:7px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 1px,transparent 1px 3px);border:1px solid var(--color-neutral-500)"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700;color:var(--color-neutral-600)">WS-06</span><span style="font-size:10.5px;color:var(--color-neutral-600)">维护</span><span style="margin-left:auto;font-size:9.5px;color:var(--color-neutral-500)">OFF</span></div>
|
||||
<div style="border-top:1px solid var(--color-divider);margin-top:8px;padding-top:8px;font-size:10.5px;color:var(--color-neutral-600);font-family:ui-monospace,Menlo,monospace">UCR 96.8% · 介入 1.8/百h</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">运行中任务 · 2</h6>
|
||||
<table class="table">
|
||||
<thead><tr><th>RUN</th><th>工位</th><th>模板 / 固件</th><th style="width:150px">进度</th><th>心跳</th><th>ETA</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:12px"><a href="LiveRun.dc.html" style="text-decoration:none">run-…0727-0001</a></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:12px">WS-01</td>
|
||||
<td><div style="font-size:12.5px">fw_ab_powerstate_regression</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--color-neutral-600)">FW_3.3.0-rc2(B)</div></td>
|
||||
<td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:67%;background-color:var(--color-accent);background-image:repeating-linear-gradient(90deg,rgba(242,242,243,.3) 0 6px,transparent 6px 14px);animation:omScan 1.1s linear infinite"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">67/100</span></div></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">{{ hb01 }}</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">23:40</td>
|
||||
<td><a class="btn btn-ghost" href="LiveRun.dc.html" style="font-size:12px">监视</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:12px">run-…0727-0002</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:12px">WS-02</td>
|
||||
<td><div style="font-size:12.5px">fw_retention_72h</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--color-neutral-600)">FW_3.2.1(A)</div></td>
|
||||
<td><div style="display:flex;align-items:center;gap:7px"><div style="flex:1;height:5px;border:1px solid var(--color-divider)"><div style="animation:omGrow .9s cubic-bezier(.2,.7,.2,1) both;height:100%;width:71%;background-color:var(--color-accent);background-image:repeating-linear-gradient(90deg,rgba(242,242,243,.3) 0 6px,transparent 6px 14px);animation:omScan 1.1s linear infinite"></div></div><span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">51/72h</span></div></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-accent-700)">{{ hb02 }}</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">07-29</td>
|
||||
<td><a class="btn btn-ghost" href="LiveRun.dc.html" style="font-size:12px">监视</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><h6 style="margin:0;color:var(--color-neutral-700)">队列与恢复 · 3</h6><a href="Tasks.dc.html" style="margin-left:auto;font-size:11.5px">任务中心 →</a></div>
|
||||
<table class="table">
|
||||
<thead><tr><th>优先级</th><th>任务</th><th>工位</th><th>计划时间</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="tag tag-accent">P1</span></td><td style="font-size:12.5px">fw_coldboot_matrix · 500 次冷启动</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px">WS-05</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">今日 22:00</td><td><span class="tag tag-outline">已预约</span></td></tr>
|
||||
<tr><td><span class="tag tag-neutral">P2</span></td><td style="font-size:12.5px">fw_ab 从检查点 cycle 41 续跑</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px">WS-03</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">恢复后自动</td><td><span style="font-size:10.5px;padding:3px 9px;background:var(--color-accent-200);color:var(--color-accent-800);animation:omPulse 1.4s infinite">等待恢复</span></td></tr>
|
||||
<tr><td><span class="tag tag-neutral">P3</span></td><td style="font-size:12.5px">incoming_qc_quick · 来料抽检 12 盘</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:12px">WS-04</td><td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">明日 06:00</td><td><span class="tag tag-outline">已预约</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">事件流 · EVENT BUS</h6>
|
||||
<div style="display:flex;flex-direction:column;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;line-height:1.5">
|
||||
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:35:12</span><span style="color:var(--color-accent-800);white-space:nowrap">[OOB]</span><span>WS-03 RESET_SENT · 光耦通道 CH2 · 脉冲 250ms</span></div>
|
||||
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:33:40</span><span style="color:#8a2e22;white-space:nowrap">[RCVR]</span><span>WS-03 L2 超时,升级 L3(策略 esc-default-v2)</span></div>
|
||||
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:32:29</span><span style="color:var(--color-neutral-600);white-space:nowrap">[EVID]</span><span>WS-03 死机快照已抓取:HDMI 帧 + 串口尾部 2KB</span></div>
|
||||
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:32:07</span><span style="color:#8a2e22;white-space:nowrap">[HB]</span><span>WS-03 心跳丢失 >30s,判定 HOST_UNREACHABLE</span></div>
|
||||
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:28:51</span><span style="color:var(--color-accent-700);white-space:nowrap">[TEST]</span><span>WS-01 cycle 67 数据校验 PASS · 4k_mixed 300s</span></div>
|
||||
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:25:03</span><span style="color:var(--color-accent-700);white-space:nowrap">[TEST]</span><span>WS-01 hibernate → resume 成功 · 唤醒耗时 11.2s</span></div>
|
||||
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-500)">14:20:44</span><span style="color:var(--color-neutral-600);white-space:nowrap">[SYS]</span><span>WS-05 预检通过:磁盘/网络/OOB/工具版本/时钟同步</span></div>
|
||||
<div style="display:flex;gap:8px;padding:6px 0"><span style="color:var(--color-neutral-500)">14:18:02</span><span style="color:var(--color-neutral-600);white-space:nowrap">[SYS]</span><span>FW_3.3.0-rc3.bin 已登记 · SHA256 9f2c…41aa · 待审批</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"defaultVariant":{"editor":"enum","options":["a","b","c"],"default":"a","tsType":"string","section":"布局"},"liveTicker":{"editor":"boolean","default":true,"tsType":"boolean","section":"演示"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { variant: null, tick: 0 };
|
||||
componentDidMount() {
|
||||
this._t = setInterval(() => {
|
||||
if (this.props.liveTicker ?? true) this.setState(s => ({ tick: s.tick + 1 }));
|
||||
}, 2000);
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this._t); }
|
||||
renderVals() {
|
||||
const v = this.state.variant ?? (this.props.defaultVariant ?? 'a');
|
||||
const k = this.state.tick;
|
||||
const hb = i => ((k * 2 + i * 3) % 7 + 1) + 's 前';
|
||||
return {
|
||||
vA: v === 'a', vB: v === 'b', vC: v === 'c',
|
||||
setA: () => this.setState({ variant: 'a' }),
|
||||
setB: () => this.setState({ variant: 'b' }),
|
||||
setC: () => this.setState({ variant: 'c' }),
|
||||
hb01: hb(0), hb02: hb(1), hb04: hb(2), hb05: hb(3),
|
||||
lost03: (47 + k * 2) + 's',
|
||||
t01: (42.5 + Math.sin(k / 2) * 0.4).toFixed(1),
|
||||
t02: (45.1 + Math.cos(k / 3) * 0.3).toFixed(1)
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,597 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<!-- @template name="Deck" description="A twenty-one-slide presentation starter: cover, contents, dividers, columns, quadrants, a data table, SVG charts and timeline, bleed imagery, a quote and a close, on the theme's tokens" -->
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css" />
|
||||
<title>Industry — deck</title>
|
||||
<script src="./ds-base.js"></script>
|
||||
<script src="./image-slot.js"></script>
|
||||
<style>
|
||||
/* Template scaffolding — slide layout and projection-scale sizing only; every
|
||||
color, font, radius and shadow comes from ../../styles.css. Change one
|
||||
number here to re-size the whole deck.
|
||||
THE WHOLE-GRID DECK (review rounds 4-5, replacing the containers-only
|
||||
reading at Barron's pick): the modular grid the blueprint grammar
|
||||
implies is drawn in full on every slide — the Grid Systems cover reading
|
||||
of the same direction. Type registers baseline-ON-rule, display sizes
|
||||
step up hard (Müller-Brockmann size contrast), photographs become grid
|
||||
objects, and the system's "+" registration marks keep marking what is
|
||||
measured. The field is EQUAL-MARGINED — 120px from all four stage
|
||||
edges — and nearly every element seats on a DRAWN rule (each seat's
|
||||
comment carries its derivation). */
|
||||
deck-stage {
|
||||
--type-display: 288px; /* 140 → 288: the grid variant's size contrast */
|
||||
--type-title: 96px; /* 76 → 96 */
|
||||
--type-subtitle: 40px;
|
||||
--type-body: 34px;
|
||||
--type-small: 28px;
|
||||
--type-kicker: 24px;
|
||||
--pad-x: 120px; /* round 2 (Barron): equal field margins — 120 on all four sides */
|
||||
--page-foot-note: "Confidential — July 2026"; /* the deck-wide imprint text (page furniture; see the PAGE FURNITURE note below) */
|
||||
--pad-top: 96px;
|
||||
--pad-bottom: 120px;
|
||||
--baseline: 48px; /* grid unit — one default-text line: the 34px Barlow body at its ~1.41
|
||||
deck leading (a compact sans). The field's bottom rule is the 20th gridline (960); in
|
||||
this variant the cover/close meta seats a half-step ABOVE it, on the 936 cell-bottom
|
||||
rule (see .pin-bottom), and 960 itself stays the hard vertical budget: the bleed's
|
||||
note ends exactly ON it, the reviewed deck's own footer convention. */
|
||||
}
|
||||
|
||||
.slide {
|
||||
display: flex; flex-direction: column;
|
||||
isolation: isolate; /* the slide is its own stacking context, so the board's
|
||||
negative z-index (below) floors it above the slide's OWN background but
|
||||
beneath every in-flow and positioned child — without isolation a negative
|
||||
z-index would drop the board behind the slide background entirely, and
|
||||
with z-index:auto a positioned board paints ABOVE in-flow ink (CSS paint
|
||||
order), the opposite of the drawn-grid intent */
|
||||
background: var(--color-bg); color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
text-wrap: pretty; /* inherited: better breaks, no orphans on wrapped blocks */
|
||||
padding: var(--pad-top) var(--pad-x) var(--pad-bottom);
|
||||
print-color-adjust: exact; -webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* — the board: the whole modular grid, drawn — */
|
||||
/* The variant's one big move. The field is equal-margined: 120px from every
|
||||
stage edge (x 120..1800, y 120..960), so the frame reads symmetric and the
|
||||
bottom rule IS the deck's established 960 footer/budget line. Columns:
|
||||
four 384px modules with 48px gutters (pitch 432) — one baseline unit of
|
||||
gutter, the Grid Systems cover's wider vertical doubling. Rows: seven
|
||||
groups at pitch 120 (2.5 baseline units), each a 96px cell plus a 24px
|
||||
caption row — the cover's doubled-line rhythm, every rule top on the
|
||||
deck's 48/24px baseline lattice. Drawn as
|
||||
border + four repeating gradients (origin border-box, so offsets count
|
||||
from the field edge); zero-flow overlay like the reviewed deck's cells —
|
||||
delete it and no baseline moves. Ink rules on paper; the dividers and
|
||||
bleeds re-derive the rule color per ground (see below). Four "+" marks
|
||||
register the field corners; content-anchored crosses stay on the
|
||||
containers that keep them. */
|
||||
.board {
|
||||
--board-rule: color-mix(in srgb, var(--color-divider) 50%, transparent); /* review
|
||||
round 10 (Barron, pre-ship): the grid fades by half — scoped here so the
|
||||
chart grids and timeline line (data furniture on the same divider token)
|
||||
keep their full ink. The table has no rules of its own — the board rules
|
||||
it — so its ruling fades with the board, a deliberate part of the fade. */
|
||||
position: absolute; z-index: -1; /* beneath all content — see .slide's isolation
|
||||
note. The bleed slide overrides this: there the board deliberately lies ON
|
||||
the photograph, beneath the scrim (see .bleed .board) */
|
||||
left: var(--pad-x); top: calc(2.5 * var(--baseline));
|
||||
width: calc(1680px + 1px); height: calc(17.5 * var(--baseline) + 1px);
|
||||
border: 1px solid var(--board-rule, var(--color-divider));
|
||||
background-origin: border-box; background-clip: border-box;
|
||||
pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(to bottom, var(--board-rule, var(--color-divider)) 0 1px, transparent 1px 120px),
|
||||
repeating-linear-gradient(to bottom, transparent 0 96px, var(--board-rule, var(--color-divider)) 96px 97px, transparent 97px 120px),
|
||||
repeating-linear-gradient(to right, var(--board-rule, var(--color-divider)) 0 1px, transparent 1px 432px),
|
||||
repeating-linear-gradient(to right, transparent 0 384px, var(--board-rule, var(--color-divider)) 384px 385px, transparent 385px 432px);
|
||||
}
|
||||
|
||||
/* — shared pieces — */
|
||||
/* Baseline grid: text sits on a 48px rhythm (--baseline) measured from the
|
||||
slide's top edge. Snapped text blocks are trimmed to their cap and baseline
|
||||
edges (text-box), so a block's bottom edge IS its last baseline and the next
|
||||
block's first baseline lands N gridlines down with
|
||||
margin-top: calc(N * var(--baseline) - 1cap). Compact rhythms (chart axes
|
||||
and ticks) sit on the 24px half-step. Review the rhythm with ?baselines in
|
||||
the URL, or press B (script in the helmet) — half-step lines draw
|
||||
fainter than full ones. In this variant the board's rules are a subset of
|
||||
the same lattice: row-group tops are full gridlines, cell bottoms are
|
||||
half-steps, so "baseline on the rule" and "baseline on the grid" are one
|
||||
statement. */
|
||||
.kicker {
|
||||
position: absolute; top: calc(2 * var(--baseline) - 1cap); /* baseline on the reviewed
|
||||
deck's own kicker line (96) — now half a unit ABOVE the field's 120 top rule,
|
||||
so the rule and its corner crosses stay clean */
|
||||
left: var(--pad-x); text-box: trim-both cap alphabetic;
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: var(--type-kicker); letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--color-accent);
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
.k-right { left: auto; right: var(--pad-x); } /* the cover/close kicker rides the
|
||||
top rule flush right — the Grid Systems cover's author line */
|
||||
.slide-title {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: var(--type-title); line-height: calc(2 * var(--baseline)); letter-spacing: 0; margin: 0;
|
||||
margin-left: -0.052em; /* optical left alignment — measured for Barlow
|
||||
Condensed SemiBold in the reviewed deck; em-scaled, so it holds at 96px */
|
||||
text-box: trim-both cap alphabetic;
|
||||
}
|
||||
.pin-bottom { position: absolute; left: var(--pad-x); right: var(--pad-x); bottom: calc(var(--pad-bottom) + var(--baseline) / 2); } /* baseline
|
||||
on the 936 caption rule — inside the field, clear of the 960 corner crosses;
|
||||
the last caption row [936,960] is the imprint's band */
|
||||
.meta { display: flex; align-items: baseline; gap: 18px; font-size: var(--type-kicker); color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||
.meta span { text-box: trim-both cap alphabetic; } /* baseline on the 936 caption rule — see .pin-bottom */
|
||||
|
||||
/* PAGE FURNITURE — on by default. To remove the page numbers and footer
|
||||
text deck-wide, add data-no-page-furniture to the deck's <x-import>
|
||||
mount (a one-line tweak — it renders on the <deck-stage>); to change
|
||||
the text, edit --page-foot-note in
|
||||
the deck's token block. The imprint sits where this board documents its
|
||||
imprint's band: flush left on the axis, baseline ON the 936 caption rule
|
||||
(the last caption row [936,960] of the field), at the meta row's scale
|
||||
and 55% ink, the number tabular. Carried by the content slides (hero,
|
||||
quote, split and the half-bleeds included — on the half-bleeds it
|
||||
follows the copy half, because small muted type cannot pass the
|
||||
type-over-photography rule) AND by the dividers: a blueprint sheet
|
||||
carries its imprint on every ground, so the dividers keep it with the
|
||||
ink re-derived to the page-corner marks' 55% paper (measured against
|
||||
the steel ground in the render audit). The cover and close keep their
|
||||
meta rows on the same rule instead, and the full bleed's photograph
|
||||
owns its edges. Every slide still counts, so the printed number stays
|
||||
truthful to position. */
|
||||
deck-stage { counter-reset: page; }
|
||||
.slide { counter-increment: page; }
|
||||
.page-foot {
|
||||
position: absolute; left: var(--pad-x); bottom: calc(var(--pad-bottom) + var(--baseline) / 2); /* baseline ON the 936 caption rule, like .pin-bottom */
|
||||
font-size: var(--type-kicker);
|
||||
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||
text-box: trim-both cap alphabetic; /* bottom edge = baseline */
|
||||
}
|
||||
.page-foot::before { content: counter(page) " · "; font-feature-settings: "tnum" 1; }
|
||||
.page-foot::after { content: var(--page-foot-note); }
|
||||
deck-stage[data-no-page-furniture] .page-foot, deck-stage[data-no-page-furniture] .page-head { display: none !important; }
|
||||
.divider .page-foot { color: color-mix(in srgb, var(--color-bg) 55%, transparent); } /* the imprint survives the steel sheet in the page corners' paper-alpha ink */
|
||||
.half-bleed:not(.flip) .page-foot { left: 984px; } /* the imprint follows the copy half, seated on column 3's rule — type never sits on the photograph (deck-templates.md law) */
|
||||
|
||||
/* — cover and close — */
|
||||
/* The Grid Systems cover, in industry's inks: the full board behind a
|
||||
display line seated baseline-ON-rule (row-group 5's top, y 600), with the
|
||||
description split into two caption columns under it — a short left cell
|
||||
on the axis, the long cell on column 2 — first baselines ON the 696
|
||||
caption rule, clear of the display's descenders (~55px below 600). The
|
||||
kicker rides flush right above the field; the meta sits on the last
|
||||
caption rule (936), inside the field. Most of the grid stays empty on
|
||||
purpose. */
|
||||
.cover { justify-content: flex-start; }
|
||||
.cover .rise { position: absolute; inset: 0 var(--pad-x); }
|
||||
.display {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: var(--type-display); line-height: calc(6 * var(--baseline)); letter-spacing: 0; margin: 0;
|
||||
margin-left: -0.052em; /* optical left alignment, em-scaled — see .slide-title */
|
||||
margin-top: calc(12.5 * var(--baseline) - 1cap); /* baseline exactly on the group-5 top rule (600) */
|
||||
text-box: trim-both cap alphabetic;
|
||||
}
|
||||
.cover-sub { font-size: var(--type-small); line-height: var(--baseline); margin: 0;
|
||||
position: absolute; left: 0; max-width: 768px;
|
||||
top: calc(14.5 * var(--baseline) - 1cap); /* first baseline ON the 696 caption rule —
|
||||
clear of the 288px display's descenders (~55px below 600) */
|
||||
text-box: trim-both cap alphabetic; }
|
||||
.cover-sub-a { left: 0; max-width: 360px; }
|
||||
.cover-sub-b { left: 432px; max-width: 816px; } /* column 2's left edge (x 552) */
|
||||
|
||||
/* — contents — */
|
||||
/* Contents as the cover's caption rows: no panel, no added rules — the
|
||||
board IS the ruling. Each entry's baseline sits ON a caption rule
|
||||
(456 / 576 / 696 / 816), the 24px caption row beneath reading as the
|
||||
doubled line of the reference cover. Numbers hold the axis; labels start
|
||||
on column 2 (x 552) — secondary starts in this deck always sit on a
|
||||
column edge. */
|
||||
.toc { position: absolute; inset: 0 var(--pad-x); font-size: var(--type-subtitle); }
|
||||
.toc-row { position: absolute; left: 0; right: 0; }
|
||||
.toc-row > span { text-box: trim-both cap alphabetic; position: absolute; top: 0; }
|
||||
.toc-row > .toc-label { left: 432px; }
|
||||
.toc-num { font-size: var(--type-kicker); color: var(--color-accent); font-feature-settings: "tnum" 1; }
|
||||
.toc-row { top: calc(9.5 * var(--baseline)); } /* baseline on the 456 caption rule; the rows
|
||||
re-seat below via nth-child so markup stays the reviewed deck's */
|
||||
.toc-row:nth-child(2) { top: calc(12 * var(--baseline)); }
|
||||
.toc-row:nth-child(3) { top: calc(14.5 * var(--baseline)); }
|
||||
.toc-row:nth-child(4) { top: calc(17 * var(--baseline)); }
|
||||
.toc-row > span { top: calc(-1cap); } /* each span trims to its cap; the row's
|
||||
top IS the rule, so the baseline lands on it */
|
||||
|
||||
/* — section dividers — */
|
||||
/* Dividers keep their own ground — the deep steel blueprint sheet — and the
|
||||
variant draws the board through it in paper-alpha hairlines: the section
|
||||
break is a fresh sheet of the same grid. Ghost numeral and title seat
|
||||
baseline-ON-rule like everything else (ghost on 600, title on 816), both
|
||||
flush on the axis; the "+" page corners keep their registration spots.
|
||||
Contrast re-derived for the steel ground: paper type 12.6:1, ghost at
|
||||
~2.2:1 (a drawing showing through the sheet), board rules decorative at
|
||||
9% paper (halved in round 10's pre-ship fade). */ /* seats: ghost baseline ON the group-5 top rule (600),
|
||||
title ON the 816 caption rule — see each declaration */
|
||||
.divider { justify-content: flex-start; background: var(--color-accent-900); color: var(--color-bg); }
|
||||
.divider .board { --board-rule: color-mix(in srgb, var(--color-bg) 9%, transparent); } /* half of the pre-round-10 18% — the fade keeps the per-ground ratio */
|
||||
.divider .board > .corner { color: color-mix(in srgb, var(--color-bg) 55%, transparent); }
|
||||
.divider .rise { position: absolute; inset: 0 var(--pad-x); }
|
||||
.page-corner {
|
||||
position: absolute; width: 15px; height: 15px;
|
||||
color: color-mix(in srgb, var(--color-bg) 55%, transparent);
|
||||
}
|
||||
.page-corner::before, .page-corner::after { content: ""; position: absolute; background: currentColor; }
|
||||
.page-corner::before { left: 7px; top: 0; width: 1px; height: 100%; }
|
||||
.page-corner::after { top: 7px; left: 0; width: 100%; height: 1px; }
|
||||
.page-corner.tl { top: 41px; left: 41px; }
|
||||
.page-corner.tr { top: 41px; right: 41px; }
|
||||
.page-corner.bl { bottom: 41px; left: 41px; }
|
||||
.page-corner.br { bottom: 41px; right: 41px; }
|
||||
|
||||
/* — registration crosses (the cell-corner grammar, kept) — */
|
||||
.cell { position: absolute; border: 1px solid var(--color-divider); pointer-events: none; }
|
||||
.cell > .corner, .board > .corner { position: absolute; width: 15px; height: 15px; color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||
.cell > .corner::before, .cell > .corner::after,
|
||||
.board > .corner::before, .board > .corner::after { content: ""; position: absolute; background: currentColor; }
|
||||
.cell > .corner::before, .board > .corner::before { left: 7px; top: 0; width: 1px; height: 100%; }
|
||||
.cell > .corner::after, .board > .corner::after { top: 7px; left: 0; width: 100%; height: 1px; }
|
||||
.cell > .corner.tl, .board > .corner.tl { top: -8px; left: -8px; }
|
||||
.cell > .corner.tr, .board > .corner.tr { top: -8px; right: -8px; }
|
||||
.cell > .corner.bl, .board > .corner.bl { bottom: -8px; left: -8px; }
|
||||
.cell > .corner.br, .board > .corner.br { bottom: -8px; right: -8px; }
|
||||
/* -8px = the 7px arm offset + the 1px border: absolute offsets resolve from
|
||||
the PADDING box, inside the border — each cross centers exactly on its
|
||||
rule intersection, the reviewed deck's convention. */
|
||||
.ghost-num {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 480px; line-height: 1; letter-spacing: 0;
|
||||
color: var(--color-accent-700);
|
||||
position: absolute; top: calc(12.5 * var(--baseline) - 1cap); /* baseline on the
|
||||
group-5 top rule (600) */
|
||||
text-box: trim-both cap alphabetic;
|
||||
margin-left: -0.036em; /* ghosts lead with "0" — optical value from the reviewed deck, em-scaled */
|
||||
}
|
||||
.divider .slide-title { position: absolute; top: calc(17 * var(--baseline) - 1cap); } /* baseline on the 816 caption rule */
|
||||
|
||||
/* — content slides — */
|
||||
.content { padding-top: calc(5 * var(--baseline)); /* title baseline on the 5th gridline —
|
||||
row 2's top rule (240), clear of the pinned kicker row */ }
|
||||
.content > .slide-title, .content > .chart-head { margin-top: -1cap; } /* cap-trimmed: the baseline, not the cap top, sits on the rule */
|
||||
.content > .bullets { margin-top: calc(2.5 * var(--baseline) - 1cap); } /* first baseline on
|
||||
the group-3 top rule (360), one row group below the title's */
|
||||
.content > figure { margin-top: calc(2 * var(--baseline)); } /* svg top on the 336 caption
|
||||
rule; the plots' axis line (svg y 360) lands ON the 696 caption rule */
|
||||
.content > .table { margin-top: calc(0.5 * var(--baseline)); } /* table top at 264: the 96px
|
||||
header row closes its baseline ON the group-3 top rule (360) */
|
||||
.content > .chart-caption { margin-top: calc(2 * var(--baseline) - 1cap); }
|
||||
.bullets { display: flex; flex-direction: column; max-width: 1220px; margin: 0; padding: 0; list-style: none; font-size: var(--type-body); }
|
||||
.bullets li { position: relative; line-height: var(--baseline); text-box: trim-both cap alphabetic; }
|
||||
.bullets li + li { margin-top: calc(2.5 * var(--baseline) - 1cap); } /* one thought per
|
||||
row group: single-line bullets seat on successive group-top rules
|
||||
(360 / 480 / 600) — the variant's pitch (the reviewed deck: every other
|
||||
gridline) */
|
||||
.bullets li::before, .bullets li::after { content: ""; position: absolute; background: var(--color-accent); }
|
||||
.bullets li::before { left: -37px; top: calc(0.5cap - 8px); width: 2px; height: 16px; }
|
||||
.bullets li::after { left: -44px; top: calc(0.5cap - 1px); width: 16px; height: 2px; }
|
||||
|
||||
/* — column layouts — */
|
||||
/* Columns are the board's own bays: the two-column setting takes two
|
||||
modules per column (x 120 and 984, 816px each, the drawn 48px gutter
|
||||
between), and the three-column setting takes one module apiece (x 120,
|
||||
552, 984) leaving module 4 DELIBERATELY empty — the Grid Systems cover's
|
||||
empty-cell statement; on a four-module board three equal full-width
|
||||
columns cannot seat on drawn rules, so the grid wins and the measure
|
||||
narrows (a module runs ≈21 characters of condensed head, ≈26 of small
|
||||
note — notes, not essays). No rules are added: the board is the ruling.
|
||||
Seats: head baselines ON the group-3 top rule (360), note baselines ON
|
||||
the 456 caption rule, wrapped lines riding the 48px lattice below. */
|
||||
.cols { display: grid; column-gap: var(--baseline); margin-top: calc(2.5 * var(--baseline)); /* container top ON the group-3 rule; the cap term lives on the h3, where it resolves in the heading face (the two-family decks' -1cap lesson) */ }
|
||||
.cols-2 { grid-template-columns: 816px 816px; }
|
||||
.cols-3 { grid-template-columns: repeat(3, 384px); }
|
||||
.cols h3 { font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: var(--type-subtitle); line-height: var(--baseline); margin: 0;
|
||||
margin-top: -1cap; /* baseline ON the group-3 top rule (360); 1cap in the h3's own face */
|
||||
text-box: trim-both cap alphabetic; }
|
||||
.cols p { font-size: var(--type-small); line-height: var(--baseline); margin: 0;
|
||||
margin-top: calc(2 * var(--baseline) - 1cap); /* first baseline ON the 456 caption rule */
|
||||
text-box: trim-both cap alphabetic; }
|
||||
|
||||
/* — quadrants — */
|
||||
/* The board IS the matrix — no division is drawn because every division
|
||||
is already there: the vertical axis is the columns 2/3 gutter (center
|
||||
960), the horizontal axis is the group-5 top rule (600). Cells are
|
||||
two-module x two-row-group bays: heads seat their baselines ON the band
|
||||
top rules (360 upper, 600 lower — the lower heads hang from the crossing
|
||||
rule itself, the way every seated element does), one-liners ON the
|
||||
caption rules below (456 / 696), wraps on the lattice. The axis pair
|
||||
seats ON the group-7 top rule (840), left label on the axis, right label
|
||||
flush right (the cover kicker's k-right precedent), one row group clear
|
||||
of the 936 imprint. Left column = lower x; the y reading (top row
|
||||
smaller work) is carried by the cell copy — a y pair has no seat that
|
||||
doesn't fight the imprint band or the axis, so the horizontal pair does
|
||||
the labeling (the shared-spec minimum). */
|
||||
.quad { display: grid; grid-template-columns: 816px 816px; column-gap: var(--baseline);
|
||||
grid-template-rows: calc(5 * var(--baseline)) calc(5 * var(--baseline));
|
||||
margin-top: calc(2.5 * var(--baseline)); /* matrix top = the group-3 top rule (360) */ }
|
||||
.quad h3 { font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: var(--type-subtitle); line-height: var(--baseline); margin: 0;
|
||||
margin-top: -1cap; /* baseline ON the cell's band-top rule (360 / 600) */
|
||||
text-box: trim-both cap alphabetic; }
|
||||
.quad p { font-size: var(--type-small); line-height: var(--baseline); margin: 0;
|
||||
margin-top: calc(2 * var(--baseline) - 1cap); /* one-liner ON the caption rule (456 / 696) */
|
||||
text-box: trim-both cap alphabetic; }
|
||||
.quad-axis { display: flex; justify-content: space-between;
|
||||
margin-top: -1cap; /* label baselines ON the group-7 top rule (840) */
|
||||
font-size: var(--type-kicker); letter-spacing: 0.08em; text-transform: uppercase;
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||
.quad-axis span { text-box: trim-both cap alphabetic; }
|
||||
|
||||
/* — data table (component class at projection scale) — */
|
||||
/* The board rules the table: the component's own hairlines are suppressed
|
||||
(a variant style call — no rules are drawn twice) and the rows take the
|
||||
board's 120px row groups ONE-PER-ROW, each row's text baseline ON its
|
||||
caption rule (456 / 576 / 696 / 816), the header on the group-3 top rule
|
||||
(360). Columns seat on the grid columns — Role on the axis, Value on
|
||||
column 2 (552), ground on column 3 (984), Use on column 4 (1416) — and
|
||||
the swatch hangs LEFT of the axis like every other mark, its bottom edge
|
||||
on the row's rule. */
|
||||
.slide .table { max-width: 1680px; width: 1680px; font-size: 29px; font-feature-settings: "tnum" 1;
|
||||
table-layout: fixed; border-collapse: collapse; }
|
||||
/* Column plan: three 432px columns — one grid module each — and the rest
|
||||
to Use. Carried on the header row's cells (table-layout: fixed reads row
|
||||
one) rather than a <colgroup>: the Design Component pipeline parses the
|
||||
template outside a real <table> context, where col elements are dropped,
|
||||
and a fixed-layout table without them would fall back to equal columns. */
|
||||
.slide .table th:nth-child(-n+3) { width: 432px; }
|
||||
.slide .table thead tr { height: calc(2 * var(--baseline)); }
|
||||
.slide .table tbody tr { height: calc(2.5 * var(--baseline)); } /* one board row group per data row */
|
||||
.slide .table th, .slide .table td {
|
||||
text-box: trim-both cap alphabetic; text-align: left; font-weight: 400;
|
||||
border: none; background: none; vertical-align: bottom;
|
||||
padding: 0 24px calc(0.5 * var(--baseline)) 0;
|
||||
}
|
||||
.slide .table th { font-size: var(--type-kicker); }
|
||||
.slide .table thead th { padding-bottom: 0; } /* bottom-anchored: the header row closes ON the
|
||||
group-3 top rule (360), each data row's LAST baseline sits 24px above its row's bottom —
|
||||
on the caption rules 456 / 576 / 696 / 816 — and a wrapped cell grows upward into
|
||||
its own cell instead of past the field (the vertical budget, kept by construction) */
|
||||
.slide .table tbody tr:hover { background: none; }
|
||||
.slide .table th:first-child, .slide .table td:first-child { padding-left: 0; position: relative; }
|
||||
.slide .table th:last-child, .slide .table td:last-child { padding-right: 0; }
|
||||
.slide .table .swatch { position: absolute; left: -72px; bottom: calc(0.5 * var(--baseline)); } /* hangs
|
||||
left of the axis like every mark; bottom edge ON the row's rule */
|
||||
.swatch { display: inline-block; width: 48px; height: 48px; border-radius: 0; /* square-corner
|
||||
doctrine; 48px = one grid unit, bottom edge on the row's rule */ box-shadow: var(--shadow-sm); }
|
||||
|
||||
/* — hero figure — */
|
||||
/* The variant's loudest size contrast: the figure at 432px, flush on the
|
||||
axis, baseline ON the group-5 top rule (600); the caption moves to column
|
||||
3 (x 984), its first baseline ON the 696 caption rule — the number and
|
||||
its note read corner-to-corner across the empty grid. */
|
||||
.hero { justify-content: flex-start; }
|
||||
.hero-num {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 432px; line-height: 1; letter-spacing: 0; margin: 0;
|
||||
color: var(--color-accent);
|
||||
position: absolute; left: var(--pad-x); top: calc(12.5 * var(--baseline) - 1cap);
|
||||
text-box: trim-both cap alphabetic;
|
||||
margin-left: -0.010em; /* leads with the near-bearingless "1" — reviewed value, em-scaled */
|
||||
}
|
||||
.hero-caption { font-size: var(--type-small); line-height: var(--baseline); margin: 0;
|
||||
position: absolute; left: 984px; top: calc(14.5 * var(--baseline) - 1cap); max-width: 816px;
|
||||
text-box: trim-both cap alphabetic; }
|
||||
|
||||
/* — charts (static SVG on the tokens; no script, no chart library) — */
|
||||
/* The plots seat their anatomy ON drawn rules — svg top at 336 (a caption
|
||||
rule), the x-axis ON the 696 caption rule, and the line chart's 10k
|
||||
gridlines (120px apart, the row-group pitch) phased by the axis onto the
|
||||
drawn caption rules (576 / 456). Bar x-geometry is derived for the 1680
|
||||
field. The board's column rules pass behind the plots — the drawing
|
||||
stays on its grid. */
|
||||
.chart { display: block; width: 100%; height: auto; overflow: visible; }
|
||||
.chart text {
|
||||
font-family: var(--font-body); font-size: 24px;
|
||||
fill: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
.chart .grid, .chart .axis { stroke: var(--color-divider); stroke-width: 1; }
|
||||
.chart .bar { fill: none; stroke: var(--color-neutral-600); stroke-width: 1; }
|
||||
.chart .bar-hi { fill: var(--color-accent); stroke: none; }
|
||||
.chart .val { fill: color-mix(in srgb, var(--color-text) 70%, transparent); }
|
||||
.chart .ref { stroke: var(--color-accent-700); stroke-width: 1; stroke-dasharray: 8 8; }
|
||||
.chart .ref-label { fill: var(--color-accent-700); }
|
||||
.chart .hi-label { fill: var(--color-accent-700); }
|
||||
.chart .series-a, .key .series-a { stroke: var(--color-accent); stroke-width: 3; fill: none; }
|
||||
.chart .series-b, .key .series-b { stroke: var(--color-neutral-800); stroke-width: 3; fill: none; stroke-dasharray: 10 8; }
|
||||
.chart-caption { font-size: 25px; line-height: var(--baseline); max-width: 88ch; margin: 0; text-box: trim-both cap alphabetic; }
|
||||
.chart-head { display: flex; align-items: baseline; justify-content: space-between;
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: var(--type-title); }
|
||||
.chart-legend { display: flex; align-items: baseline; gap: 16px; font-family: var(--font-body); font-weight: 400; font-size: var(--type-kicker); color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||
.chart-legend span { text-box: trim-both cap alphabetic; }
|
||||
.chart-legend .key { width: 60px; height: 4px; overflow: visible; position: relative; top: -7px; }
|
||||
.chart-legend .key:not(:first-child) { margin-left: 24px; }
|
||||
.chart .tl-line { stroke: var(--color-divider); stroke-width: 1; }
|
||||
.chart .tl-node { stroke: var(--color-neutral-600); stroke-width: 1.5; }
|
||||
.chart .tl-node-hi-fill { fill: var(--color-accent); }
|
||||
.chart .tl-node-hi { stroke: var(--color-bg); stroke-width: 1.5; }
|
||||
.chart .tl-date { fill: color-mix(in srgb, var(--color-text) 55%, transparent); letter-spacing: 0.08em; }
|
||||
.chart .tl-date-hi { fill: var(--color-accent-700); letter-spacing: 0.08em; }
|
||||
.chart .tl-label { fill: var(--color-text); font-size: 30px; font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
|
||||
/* The timeline figure drops to the mid-field so its line sits ON the
|
||||
group-4 top rule (480): svg top at 384, dates above, labels below, the
|
||||
notes' baselines landing exactly ON the group-5 top rule (600). Node
|
||||
pitch is 360 — milestone positions are data, not grid furniture. The
|
||||
column-seated alternative (pitch 432, nodes on 120/552/984/1416/1800,
|
||||
re-checked for the round-2 geometry) stays rejected: the fifth
|
||||
milestone's ~340px notes would either overflow the stage or,
|
||||
right-anchored, collide with the fourth's — and mixed anchoring
|
||||
re-breaks the reviewed one-left-edge-per-milestone rule. */
|
||||
.fig-timeline { margin-top: calc(3 * var(--baseline)) !important; }
|
||||
|
||||
/* — image slide — */
|
||||
/* The photograph becomes a grid object: columns 3-4 by row-groups 2-7
|
||||
exactly (x 984..1800, y 240..936), the blueprint frame's "+" marks seated
|
||||
on board intersections. The copy keeps the axis in columns 1-2. */
|
||||
.split { flex-direction: row; align-items: flex-start; gap: 0; padding-top: calc(5 * var(--baseline)); }
|
||||
.split-copy { flex: none; width: 768px; display: flex; flex-direction: column; }
|
||||
.split-copy .slide-title { margin-top: -1cap; }
|
||||
.split-copy .note { font-size: var(--type-small); line-height: var(--baseline); margin: calc(2.5 * var(--baseline) - 1cap) 0 0;
|
||||
text-box: trim-both cap alphabetic; max-width: 768px; }
|
||||
.split-figure { position: absolute; left: 984px; top: calc(5 * var(--baseline)); width: 816px; height: calc(14.5 * var(--baseline)); margin: 0; }
|
||||
.split-figure image-slot { width: 100%; height: 100%; } /* the grid-object box (columns 3-4 ×
|
||||
row-groups 2-7) does the framing; cover is the component's own default */
|
||||
.split-figure > .corner { pointer-events: none; } /* the registration crosses overlap the
|
||||
slot by a few px — decorative marks must not eat its hover/drop events
|
||||
(image-slot usage contract); scoped here rather than in styles.css, whose
|
||||
.corner serves the whole system */
|
||||
|
||||
/* — full-bleed image slide — */
|
||||
/* The board is drawn OVER the photograph in paper-alpha — the blueprint
|
||||
laid onto the image — and UNDER the scrim, so the rules fade out exactly
|
||||
where the type needs its measured ground. Type treatment unchanged from
|
||||
the reviewed deck (it passes the type-over-photography rule): full-ink
|
||||
note, kicker stepped to accent-700. */
|
||||
.bleed .board { --board-rule: color-mix(in srgb, var(--color-bg) 13%, transparent); /* half of the pre-round-10 25%, rounded to a whole step */
|
||||
z-index: auto; /* the one slide where the board rides ABOVE its photograph:
|
||||
DOM-order painting — after the positioned figure, before the positioned
|
||||
scrim — so photo → board → scrim → copy stack as documented */ }
|
||||
.bleed .board > .corner { color: color-mix(in srgb, var(--color-bg) 55%, transparent); }
|
||||
.bleed-figure { position: absolute; inset: 0; margin: 0; }
|
||||
.bleed-figure image-slot { width: 100%; height: 100%; } /* framing is the component's (fit=cover default) */
|
||||
.bleed-scrim {
|
||||
position: absolute; inset: 0; pointer-events: none; /* the slot beneath is interactive (hover controls, drops) — overlays must not eat its events (image-slot usage contract) */
|
||||
background:
|
||||
linear-gradient(to top,
|
||||
color-mix(in srgb, var(--color-bg) 98%, transparent),
|
||||
color-mix(in srgb, var(--color-bg) 95%, transparent) 24%,
|
||||
color-mix(in srgb, var(--color-bg) 60%, transparent) 42%,
|
||||
transparent 66%),
|
||||
linear-gradient(to bottom,
|
||||
color-mix(in srgb, var(--color-bg) 92%, transparent),
|
||||
color-mix(in srgb, var(--color-bg) 80%, transparent) 12%,
|
||||
transparent 26%);
|
||||
}
|
||||
.bleed-copy { position: absolute; left: var(--pad-x); right: var(--pad-x); bottom: var(--pad-bottom); pointer-events: none; } /* text over the slot — must not eat its hover/drop events */
|
||||
.bleed .kicker { color: var(--color-accent-700); pointer-events: none; /* sits on the photograph — same usage contract as the scrim */ }
|
||||
.bleed-copy .note {
|
||||
font-size: var(--type-small); line-height: var(--baseline); max-width: 56ch;
|
||||
margin: calc(2 * var(--baseline) - 1cap) 0 0;
|
||||
text-box: trim-both cap alphabetic;
|
||||
color: var(--color-text);
|
||||
} /* bottom-anchored block: the note's LAST baseline sits ON the field's 960
|
||||
bottom rule; its earlier baselines (864/912) and the title's (768) ride the
|
||||
48px lattice, not drawn rules — over a photograph the scrim, not the board,
|
||||
is the ground (the board fades out beneath it) */
|
||||
|
||||
/* — half-bleed image slides — */
|
||||
/* The photograph takes grid columns, not a raw half-stage: it bleeds to
|
||||
three edges and stops at the gutter (base: columns 1-2, inner edge on
|
||||
the 936 gutter rule; .flip mirrors to columns 3-4, starting exactly on
|
||||
column 3's rule at 984), and the copy starts on a column rule — x 984
|
||||
(column 3) for the base, the axis for the mirror. The board draws first,
|
||||
the photograph covers its half. */
|
||||
.half-bleed { padding-top: calc(5 * var(--baseline)); }
|
||||
.half-bleed > .slide-title { margin-top: -1cap; max-width: 816px; }
|
||||
.half-bleed .note {
|
||||
font-size: var(--type-small); line-height: var(--baseline); max-width: 816px;
|
||||
margin: calc(2 * var(--baseline) - 1cap) 0 0;
|
||||
text-box: trim-both cap alphabetic;
|
||||
}
|
||||
.half-bleed .bullets { margin-top: calc(2.5 * var(--baseline) - 1cap); max-width: 816px; }
|
||||
.half-bleed .bullets li + li { margin-top: calc(4 * var(--baseline) - 1cap); } /* items here
|
||||
run up to two lines: the 4-unit gap from a two-line item's last baseline seats
|
||||
each bullet's FIRST baseline back on a group-top rule (360 / 600 / 840), and a
|
||||
one-line item simply leaves more air before the next */
|
||||
.half-bleed-figure { position: absolute; inset: 0 auto 0 0; width: 936px; margin: 0; }
|
||||
.half-bleed-figure image-slot { width: 100%; height: 100%; } /* framing is the component's (fit=cover default) */
|
||||
.half-bleed:not(.flip) > .slide-title { margin-left: calc(864px - 0.052em); } /* column 3 (x 984), preserving the optical shift */
|
||||
.half-bleed:not(.flip) .note,
|
||||
.half-bleed:not(.flip) .bullets { margin-left: 864px; }
|
||||
.half-bleed:not(.flip) .kicker { left: 984px; }
|
||||
.half-bleed.flip .half-bleed-figure { inset: 0 0 0 auto; width: 936px; }
|
||||
/* With the photograph ending on the 936 gutter rule and the copy on column
|
||||
3 (984), the 48px gutter is the hanging margin: the crosses center on the
|
||||
gutter's own centerline (x 960) with 16px of paper on either side —
|
||||
"whatever sits behind them" now means the gutter itself. */
|
||||
.half-bleed:not(.flip) .bullets li::before { left: -25px; }
|
||||
.half-bleed:not(.flip) .bullets li::after { left: -32px; }
|
||||
|
||||
/* — quote — */
|
||||
/* Flush on the axis, airy MB leading: 64px poster lines on a two-unit
|
||||
(96px) leading, first baseline ON the group-3 top rule (360; the deck's
|
||||
copy sets in two lines, 360/456 — top rule and caption rule); the
|
||||
attribution drops to the 696 caption rule — see the figcaption note. */
|
||||
.quote { justify-content: flex-start; }
|
||||
.quote .rise { position: absolute; inset: 0 var(--pad-x); }
|
||||
.quote blockquote {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 64px; line-height: calc(2 * var(--baseline)); letter-spacing: 0;
|
||||
max-width: 1200px; margin: 0;
|
||||
margin-top: calc(7.5 * var(--baseline) - 1cap); /* first baseline on the group-3 top rule (360) */
|
||||
text-box: trim-both cap alphabetic;
|
||||
text-indent: -0.316em; /* hang the opening “ (measured advance in the Condensed 600) */
|
||||
}
|
||||
.quote figcaption { font-size: var(--type-small); margin: 0;
|
||||
position: absolute; top: calc(14.5 * var(--baseline) - 1cap); /* baseline on the 696
|
||||
caption rule — a full row-group below the quote's two-line setting (360/456),
|
||||
chosen for air over the nearer 576 rule; re-seat if the quote re-wraps */
|
||||
text-box: trim-both cap alphabetic;
|
||||
text-indent: -0.885em; /* hang the leading em-dash + space (measured advance in Barlow Regular) */ }
|
||||
|
||||
/* Review aid: the baseline grid drawn as a hairline under every --baseline
|
||||
step. Debug-only — toggled by ?baselines or the B key; not part of the look. */
|
||||
deck-stage[data-baselines] .slide::after {
|
||||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(to bottom,
|
||||
color-mix(in srgb, var(--color-accent) 35%, transparent) 0 1px,
|
||||
transparent 1px var(--baseline)),
|
||||
repeating-linear-gradient(to bottom,
|
||||
transparent 0 calc(var(--baseline) / 2),
|
||||
color-mix(in srgb, var(--color-accent) 16%, transparent) calc(var(--baseline) / 2) calc(var(--baseline) / 2 + 1px),
|
||||
transparent calc(var(--baseline) / 2 + 1px) var(--baseline));
|
||||
}
|
||||
|
||||
/* Entrance — end-state is the base style; animate from hidden, gated on the
|
||||
active slide and the motion preference, so print and reduced-motion are safe. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
[data-deck-active] .rise { animation: rise 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(14px); } }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Review aid: baseline-grid overlay — add ?baselines to the URL, or press B to toggle.
|
||||
// This runs from <helmet>, before the deck renders, so the stage is looked up when it is
|
||||
// needed instead of captured up front; the window flag keeps a second evaluation of
|
||||
// this helmet script (a recreated script node) from binding the B key twice.
|
||||
(() => {
|
||||
if (window.__deckBaselineAid) return;
|
||||
window.__deckBaselineAid = true;
|
||||
const stage = () => document.querySelector('deck-stage');
|
||||
if (new URLSearchParams(location.search).has('baselines')) {
|
||||
let tries = 600; // wait out the deck's first render (up to ~10s of frames)
|
||||
const arm = () => { const s = stage(); if (s) s.setAttribute('data-baselines', ''); else if (tries-- > 0) requestAnimationFrame(arm); };
|
||||
arm();
|
||||
}
|
||||
addEventListener('keydown', (e) => {
|
||||
const t = e.target;
|
||||
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return; // typing guard, as in deck-stage's own keys
|
||||
if ((e.key === 'b' || e.key === 'B') && !e.metaKey && !e.ctrlKey && !e.altKey) stage()?.toggleAttribute('data-baselines');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</helmet>
|
||||
<x-import component-from-global-scope="deck-stage" from="./deck-stage.js" width="1920" height="1080" hint-size="100%,100%">
|
||||
|
||||
</x-import>
|
||||
</x-dc>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,272 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
@keyframes omPulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||
</style>
|
||||
</helmet>
|
||||
<dc-import name="ConsoleNav" active="evidence" hint-size="100%,92px"></dc-import>
|
||||
<div data-screen-label="证据浏览器" style="max-width:1400px;margin:0 auto;padding:22px 24px 64px;font-family:var(--font-body);animation:omIn .5s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.18em;color:var(--color-accent-700);font-weight:500">04 · EVIDENCE</div>
|
||||
<h2 style="margin:2px 0 0;font-size:30px">证据浏览器</h2>
|
||||
<div class="text-muted" style="font-size:12.5px;margin-top:2px">失败时间线 · Evidence Bundle · 每个结论回链到原始证据</div>
|
||||
</div>
|
||||
<span style="margin-left:auto"></span>
|
||||
<button class="btn btn-secondary" style="font-size:12.5px">创建复现任务</button>
|
||||
<button class="btn btn-secondary" style="font-size:12.5px">生成缺陷草稿</button>
|
||||
<button class="btn btn-primary" style="gap:7px">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline stroke-linejoin="round" stroke-linecap="round" points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||
下载证据包 · 247 MB
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:290px 1fr 330px;gap:18px;align-items:start">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">失败记录 · 近 7 天</h6>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">3 / 214</span>
|
||||
</div>
|
||||
<input class="input" placeholder="搜索 run / 指纹 / 状态码…" style="font-size:12.5px;min-height:32px">
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<sc-for list="{{ failures }}" as="f" hint-placeholder-count="3">
|
||||
<div onClick="{{ f.pick }}" style="{{ f.itemStyle }}">
|
||||
<div style="display:flex;align-items:center;gap:7px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700">{{ f.run }}</span>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:9.5px;color:var(--color-neutral-500)">{{ f.when }}</span>
|
||||
</div>
|
||||
<div style="margin:5px 0 4px"><span style="{{ f.tagStyle }}">{{ f.code }}</span></div>
|
||||
<div style="font-size:12px;line-height:1.45;color:var(--color-neutral-800)">{{ f.summary }}</div>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500);margin-top:4px">{{ f.meta }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;font-size:10px;color:var(--color-neutral-600);border-top:1px solid var(--color-divider);padding-top:8px;flex-wrap:wrap">
|
||||
<span><span style="display:inline-block;width:9px;height:8px;background:var(--color-accent-800);vertical-align:-1px"></span> DUT_*</span>
|
||||
<span><span style="display:inline-block;width:9px;height:8px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);vertical-align:-1px"></span> INFRA_*</span>
|
||||
<span><span style="display:inline-block;width:9px;height:8px;border:1px dashed var(--color-neutral-500);vertical-align:-1px"></span> UNKNOWN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:700">{{ selRun }}</span>
|
||||
<span style="{{ selTagStyle }}">{{ selCode }}</span>
|
||||
<span class="tag tag-outline" style="font-size:10px">指纹 {{ selFs }}</span>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--color-neutral-600)">{{ selMeta }}</span>
|
||||
</div>
|
||||
<div style="font-size:13.5px;color:var(--color-neutral-800)">{{ selHeadline }}</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-700)">
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">HOST-L02 · Ubuntu 22.04 · 6.5.0-41</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">BIOS F26b</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">FW_3.3.0-rc2 · slot 2</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">nvme-cli 2.9.1 · fio 3.36</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">workflow v3 · sig 8e02…c4</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:2px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0 0 10px;color:var(--color-neutral-700)">事件时间线 · TIMELINE</h6>
|
||||
<div style="display:flex;flex-direction:column">
|
||||
<sc-for list="{{ events }}" as="ev" hint-placeholder-count="8">
|
||||
<div style="display:flex;gap:12px;position:relative;padding-bottom:14px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--color-neutral-500);width:58px;text-align:right;padding-top:2px;flex:none">{{ ev.t }}</span>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;flex:none;width:14px">
|
||||
<span style="{{ ev.dotStyle }}"></span>
|
||||
<span style="{{ ev.lineStyle }}"></span>
|
||||
</div>
|
||||
<div style="flex:1;margin-top:-2px">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="{{ ev.laneStyle }}">{{ ev.lane }}</span>
|
||||
<span style="{{ ev.titleStyle }}">{{ ev.title }}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--color-neutral-700);margin-top:2px;line-height:1.5">{{ ev.detail }}</div>
|
||||
<sc-if value="{{ ev.links }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;gap:6px;margin-top:5px;flex-wrap:wrap">
|
||||
<sc-for list="{{ ev.linkList }}" as="lk" hint-placeholder-count="2">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;border:1px solid var(--color-accent-400);color:var(--color-accent-700);padding:2px 8px;cursor:pointer">{{ lk }}</span>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">AI 摘要 · 仅引用证据,不做最终判断</h6>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-600)">置信度 0.82 · 本地模型</span>
|
||||
</div>
|
||||
<div style="font-size:13px;line-height:1.7;color:var(--color-neutral-800)">S4 唤醒后 DUT 未完成链路训练即被访问:kernel 在唤醒后 3s 内未出现 nvme 重枚举日志 <span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;border:1px solid var(--color-accent-400);color:var(--color-accent-700);padding:1px 6px">kernel.log:L1042-1067</span>,而基线固件 A 平均 1.8s 完成 <span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;border:1px solid var(--color-accent-400);color:var(--color-accent-700);padding:1px 6px">metrics.json#resume_enum</span>。与 FS-0091 簇内 11 次历史失败特征一致(均为 S4 唤醒 + rc 系列固件)。</div>
|
||||
<div style="font-size:12px;color:var(--color-neutral-700);border:1px dashed var(--color-divider);padding:8px 10px">尚不能排除:主板 BIOS F26b 的 S4 电源时序差异(建议单变量验证:同盘同固件换 HOST-L01 复跑 20 循环)。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:6px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">EVIDENCE BUNDLE</h6>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">run-20260726-0007/</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;font-family:ui-monospace,Menlo,monospace;font-size:11px">
|
||||
<sc-for list="{{ tree }}" as="n" hint-placeholder-count="12">
|
||||
<div onClick="{{ n.toggle }}" style="{{ n.rowStyle }}">
|
||||
<span style="{{ n.chevStyle }}">{{ n.chev }}</span>
|
||||
<span>{{ n.name }}</span>
|
||||
<sc-if value="{{ n.flag }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="font-size:8.5px;background:var(--color-accent-800);color:#f2f2f3;padding:1px 5px;letter-spacing:.06em">关键</span>
|
||||
</sc-if>
|
||||
<span style="margin-left:auto;color:var(--color-neutral-500);font-size:10px">{{ n.size }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="font-size:10.5px;color:var(--color-neutral-600);border-top:1px solid var(--color-divider);padding-top:8px;line-height:1.6">manifest 校验通过 · 42 文件 · SHA256 全部一致<br>生成组件 evidence-bus 0.9.4 · 不可覆盖</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">恢复过程 · RECOVERY.JSON</h6>
|
||||
<div style="display:flex;flex-direction:column;font-family:ui-monospace,Menlo,monospace;font-size:10.5px">
|
||||
<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-600)">L1 Agent 软恢复</span><span style="color:#8a2e22">FAIL · 60s</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-600)">L2 OS 通道重启</span><span style="color:#8a2e22">FAIL · 120s</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-600)">L3 带外 Reset</span><span style="color:var(--color-accent-700)">OK · 2m17s</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:4px 0"><span style="color:var(--color-neutral-600)">总耗时 / 介入</span><span>4m12s · 无人</span></div>
|
||||
</div>
|
||||
<a href="LiveRun.dc.html" style="font-size:11.5px">回放当时的实时视图 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":980}}">
|
||||
class Component extends DCLogic {
|
||||
state = { sel: 0, open: { environment: true, device: false, host: true, test: false, oob: true, recovery: false, summary: true } };
|
||||
renderVals() {
|
||||
const mono = { fontFamily: 'ui-monospace,Menlo,monospace' };
|
||||
const tagOf = code => {
|
||||
if (code.startsWith('DUT')) return { ...mono, fontSize: '10px', padding: '2px 8px', background: 'var(--color-accent-800)', color: '#f2f2f3', letterSpacing: '.03em' };
|
||||
if (code.startsWith('INFRA')) return { ...mono, fontSize: '10px', padding: '2px 8px', background: 'repeating-linear-gradient(135deg,rgba(29,31,32,.12) 0 2px,transparent 2px 5px)', border: '1px solid var(--color-divider)', color: 'var(--color-neutral-800)', letterSpacing: '.03em' };
|
||||
if (code === 'UNKNOWN') return { ...mono, fontSize: '10px', padding: '2px 8px', border: '1px dashed var(--color-neutral-500)', color: 'var(--color-neutral-700)', letterSpacing: '.03em' };
|
||||
return { ...mono, fontSize: '10px', padding: '2px 8px', background: 'var(--color-neutral-200)', color: 'var(--color-neutral-800)', letterSpacing: '.03em' };
|
||||
};
|
||||
const F = [
|
||||
{ run: 'run-20260726-0007', when: '昨日 02:29', code: 'DUT_ENUMERATION_FAILURE', fs: 'FS-0091', summary: 'S4 唤醒后 DUT 未枚举,链路未训练', meta: 'WS-03 · cycle 41/100 · FW_3.3.0-rc2', headline: '休眠唤醒后 nvme0n1 未出现;L1/L2 恢复失败,L3 带外 Reset 后设备恢复,任务在检查点冻结。' },
|
||||
{ run: 'run-20260726-0011', when: '昨日 11:47', code: 'UNKNOWN', fs: 'FS-0102', summary: '校验超时且证据不足,等待工程师分类', meta: 'WS-02 · cycle 12/50 · FW_3.2.1', headline: 'verify_hash 超时,但主机、DUT 与网络信号均正常 — 证据不足,已标记待人工分类。' },
|
||||
{ run: 'run-20260725-0018', when: '07-25 23:12', code: 'INFRA_HOST_OS_FAILURE', fs: 'FS-0088', summary: '蓝屏 DRIVER_POWER_STATE_FAILURE', meta: 'WS-01 · cycle 88/100 · FW_3.3.0-rc2', headline: 'Windows 蓝屏(电源状态驱动),判定为基础设施故障,不计入 DUT 结论;L4 AC 循环后恢复。' }
|
||||
];
|
||||
const failures = F.map((f, i) => ({
|
||||
...f,
|
||||
pick: () => this.setState({ sel: i }),
|
||||
tagStyle: tagOf(f.code),
|
||||
itemStyle: {
|
||||
border: i === this.state.sel ? '1px solid var(--color-accent-600)' : '1px solid var(--color-divider)',
|
||||
background: i === this.state.sel ? 'var(--color-accent-100)' : 'transparent',
|
||||
padding: '10px 11px', cursor: 'pointer', transition: 'border-color .15s ease, background .15s ease'
|
||||
}
|
||||
}));
|
||||
const sel = F[this.state.sel];
|
||||
const EV = {
|
||||
0: [
|
||||
{ t: '02:28:19', lane: 'TEST', kind: 'n', title: 'cycle 41 · host.hibernate → S4', detail: '前序 40 循环全部 PASS · 检查点 cycle 40 已写入', links: null },
|
||||
{ t: '02:29:44', lane: 'HOST', kind: 'n', title: '唤醒完成 · resume 11.8s', detail: '唤醒源 RTC · Agent 心跳恢复正常', links: null },
|
||||
{ t: '02:29:47', lane: 'DUT', kind: 'f', title: 'dut.verify_enumeration 失败', detail: 'nvme0n1 未出现 · PCIe 链路未完成训练 · 重试 3 次无效', links: ['host/kernel.log', 'host/device-enumeration.json'] },
|
||||
{ t: '02:29:48', lane: 'EVID', kind: 'e', title: '失败现场取证(恢复之前)', detail: 'HDMI 帧 ×3 · dmesg 尾部 · SMART 不可读已记录 · lspci 快照', links: ['oob/screenshots/', 'device/error-log.json'] },
|
||||
{ t: '02:30:21', lane: 'RCVR', kind: 'f', title: 'L1 Agent 软恢复 → 失败', detail: 'PCIe rescan 无效 · 设备节点仍缺失 (60s)', links: null },
|
||||
{ t: '02:31:07', lane: 'HB', kind: 'f', title: '主机心跳丢失', detail: 'Agent 停止上报 · ping 不可达 · 判定 HOST_UNREACHABLE', links: ['oob/heartbeat.ndjson'] },
|
||||
{ t: '02:31:40', lane: 'RCVR', kind: 'f', title: 'L2 OS 通道重启 → 失败', detail: 'SSH 超时 120s · 按策略 esc-default-v2 升级', links: null },
|
||||
{ t: '02:33:02', lane: 'OOB', kind: 'r', title: 'L3 带外 Reset 已执行', detail: '光耦 CH2 · 脉冲 250ms · 供电动作已留痕', links: ['oob/power-actions.ndjson'] },
|
||||
{ t: '02:35:19', lane: 'HOST', kind: 'r', title: '主机上线 · 指纹一致', detail: 'Agent 自启 · BIOS/OS/驱动与任务登记一致', links: null },
|
||||
{ t: '02:35:24', lane: 'DUT', kind: 'r', title: 'DUT 重新枚举成功', detail: 'Gen4 x4 · 1.8s · SMART 正常 · 数据区未写入', links: ['device/smart-health.json'] },
|
||||
{ t: '02:35:31', lane: 'SYS', kind: 'c', title: '检查点对账 · 任务冻结', detail: '物理状态 = cycle 41 起点 · 等待复跑决策(不自动重试破坏性步骤)', links: null },
|
||||
{ t: '02:36:02', lane: 'SYS', kind: 'n', title: '失败指纹 FS-0091 已生成', detail: '与 11 个历史运行聚类 · 均为 S4 唤醒 + rc 系列固件', links: ['summary/failure-signature.json'] }
|
||||
],
|
||||
1: [
|
||||
{ t: '11:47:12', lane: 'TEST', kind: 'f', title: 'data.verify_hash 超时', detail: '300s 未返回 · fio 进程存活 · IO 无错误计数', links: ['test/stderr.log'] },
|
||||
{ t: '11:47:13', lane: 'EVID', kind: 'e', title: '现场取证', detail: '进程树 · 资源占用 · SMART 正常 · 网络正常', links: ['host/system.log'] },
|
||||
{ t: '11:48:40', lane: 'RCVR', kind: 'r', title: 'L1 终止校验进程并重跑 → 通过', detail: '重跑一次即 PASS · 无法稳定复现', links: null },
|
||||
{ t: '11:50:02', lane: 'SYS', kind: 'c', title: '标记 UNKNOWN · 等待分类', detail: '证据不足 · 建议补充 blktrace 后纳入观察名单', links: ['summary/ai-summary.md'] }
|
||||
],
|
||||
2: [
|
||||
{ t: '23:12:44', lane: 'HOST', kind: 'f', title: 'Windows 蓝屏', detail: 'DRIVER_POWER_STATE_FAILURE (0x9F) · 冷启动循环第 88 轮', links: ['oob/screenshots/'] },
|
||||
{ t: '23:12:45', lane: 'EVID', kind: 'e', title: 'HDMI 蓝屏帧留证', detail: 'OS 不可达状态下由带外控制器抓取', links: ['oob/screenshots/'] },
|
||||
{ t: '23:14:10', lane: 'OOB', kind: 'r', title: 'L4 AC 电源循环', detail: 'L3 Reset 无效 → 整机断电 · 安全间隔 30s', links: ['oob/power-actions.ndjson'] },
|
||||
{ t: '23:18:02', lane: 'SYS', kind: 'c', title: '判定 INFRA_HOST_OS_FAILURE', detail: '不计入 DUT 结论 · 计入基础设施误报率监控', links: ['summary/failure-signature.json'] }
|
||||
]
|
||||
}[this.state.sel];
|
||||
const laneColors = { HB: '#8a2e22', OOB: 'var(--color-accent-800)', RCVR: '#8a2e22', TEST: 'var(--color-accent-700)', SYS: 'var(--color-neutral-600)', EVID: 'var(--color-neutral-600)', DUT: 'var(--color-accent-800)', HOST: 'var(--color-neutral-700)' };
|
||||
const events = EV.map((e, i) => {
|
||||
let dot = { width: '9px', height: '9px', borderRadius: '50%', border: '1.5px solid var(--color-neutral-400)', background: 'var(--color-bg)', flex: 'none', marginTop: '4px' };
|
||||
if (e.kind === 'f') dot = { ...dot, border: '1.5px solid #b03d2e', background: '#f7e9e6' };
|
||||
if (e.kind === 'r') dot = { ...dot, border: 'none', background: 'var(--color-accent)' };
|
||||
if (e.kind === 'c') dot = { ...dot, borderRadius: 0, border: 'none', background: 'var(--color-accent-800)' };
|
||||
if (e.kind === 'e') dot = { ...dot, border: '1.5px dashed var(--color-neutral-500)' };
|
||||
return {
|
||||
...e,
|
||||
linkList: e.links || [],
|
||||
dotStyle: dot,
|
||||
lineStyle: { width: '1px', flex: 1, background: i === EV.length - 1 ? 'transparent' : 'var(--color-divider)', marginTop: '3px' },
|
||||
laneStyle: { ...mono, fontSize: '9.5px', color: laneColors[e.lane] || 'var(--color-neutral-600)', letterSpacing: '.06em', minWidth: '34px' },
|
||||
titleStyle: { fontSize: '13px', fontWeight: e.kind === 'f' || e.kind === 'r' ? 600 : 500, color: e.kind === 'f' ? '#8a2e22' : 'var(--color-text)' }
|
||||
};
|
||||
});
|
||||
const DIRS = [
|
||||
{ id: 'root', name: 'manifest.json', size: '4 KB', file: true, depth: 0 },
|
||||
{ id: 'environment', name: 'environment/', size: '4 项', kids: [['host.json', '2 KB'], ['dut.json', '2 KB'], ['firmware.json', '1 KB'], ['tool_versions.json', '3 KB']] },
|
||||
{ id: 'timeline', name: 'timeline/events.ndjson', size: '1.2 MB', file: true, depth: 0 },
|
||||
{ id: 'device', name: 'device/', size: '4 项', kids: [['identify.json', '18 KB'], ['smart-health.json', '9 KB'], ['error-log.json', '22 KB'], ['telemetry.bin', '64 MB']] },
|
||||
{ id: 'host', name: 'host/', size: '3 项', kids: [['system.log', '8.4 MB'], ['kernel.log', '2.1 MB', true], ['device-enumeration.json', '11 KB', true]] },
|
||||
{ id: 'test', name: 'test/', size: '4 项', kids: [['stdout.log', '96 MB'], ['stderr.log', '2 KB'], ['metrics.json', '340 KB'], ['hashes.json', '88 KB']] },
|
||||
{ id: 'oob', name: 'oob/', size: '4 项', kids: [['heartbeat.ndjson', '420 KB', true], ['power-actions.ndjson', '2 KB', true], ['temperature.csv', '180 KB'], ['screenshots/ ×9', '38 MB', true]] },
|
||||
{ id: 'recovery', name: 'recovery/recovery.json', size: '6 KB', file: true, depth: 0, flag: true },
|
||||
{ id: 'summary', name: 'summary/', size: '3 项', kids: [['failure-signature.json', '3 KB', true], ['ai-summary.md', '5 KB'], ['issue-draft.md', '4 KB']] }
|
||||
];
|
||||
const tree = [];
|
||||
for (const d of DIRS) {
|
||||
const isOpen = !!this.state.open[d.id];
|
||||
tree.push({
|
||||
name: d.name, size: d.size, flag: !!d.flag,
|
||||
chev: d.file ? '·' : (isOpen ? '▾' : '▸'),
|
||||
chevStyle: { width: '12px', color: d.file ? 'var(--color-neutral-400)' : 'var(--color-accent-700)', flex: 'none' },
|
||||
toggle: d.file ? null : () => this.setState(s => ({ open: { ...s.open, [d.id]: !s.open[d.id] } })),
|
||||
rowStyle: { display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 4px', cursor: d.file ? 'default' : 'pointer', borderBottom: '1px solid rgba(29,31,32,.06)' }
|
||||
});
|
||||
if (!d.file && isOpen) for (const [nm, sz, flag] of d.kids) tree.push({
|
||||
name: nm, size: sz, flag: !!flag, chev: '', toggle: null,
|
||||
chevStyle: { width: '12px', flex: 'none' },
|
||||
rowStyle: { display: 'flex', alignItems: 'center', gap: '6px', padding: '3px 4px 3px 22px', color: 'var(--color-neutral-700)', borderBottom: '1px solid rgba(29,31,32,.04)' }
|
||||
});
|
||||
}
|
||||
return {
|
||||
failures, events, tree,
|
||||
selRun: sel.run, selCode: sel.code, selFs: sel.fs, selMeta: sel.meta, selHeadline: sel.headline,
|
||||
selTagStyle: tagOf(sel.code)
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
</style>
|
||||
</helmet>
|
||||
<dc-import name="ConsoleNav" active="failures" hint-size="100%,92px"></dc-import>
|
||||
<div data-screen-label="失败中心" style="max-width:1400px;margin:0 auto;padding:22px 24px 64px;font-family:var(--font-body);animation:omIn .5s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.18em;color:var(--color-accent-700);font-weight:500">06 · FAILURES</div>
|
||||
<h2 style="margin:2px 0 0;font-size:30px">失败中心</h2>
|
||||
<div class="text-muted" style="font-size:12.5px;margin-top:2px">跨任务聚类 · 失败指纹由稳定字段构成,工程师可拆分/合并/命名,结果反哺规则</div>
|
||||
</div>
|
||||
<span style="margin-left:auto"></span>
|
||||
<input class="input" placeholder="搜索指纹 / 日志特征 / run…" style="width:240px;font-size:12.5px;min-height:34px">
|
||||
<div class="seg" style="background:var(--color-bg)">
|
||||
<label class="seg-opt"><input type="radio" name="fcat" checked="{{ cAll }}" onChange="{{ setAll }}"><span>全部 7</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="fcat" checked="{{ cDut }}" onChange="{{ setDut }}"><span>DUT_* 3</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="fcat" checked="{{ cInfra }}" onChange="{{ setInfra }}"><span>INFRA_* 2</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="fcat" checked="{{ cScript }}" onChange="{{ setScript }}"><span>SCRIPT 1</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="fcat" checked="{{ cUnk }}" onChange="{{ setUnk }}"><span>UNKNOWN 1</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:16px;align-items:center;border:1px solid var(--color-divider);padding:8px 14px;margin-bottom:16px;font-size:10.5px;color:var(--color-neutral-700);flex-wrap:wrap">
|
||||
<span style="font-size:10px;letter-spacing:.12em;color:var(--color-neutral-600)">视觉语言</span>
|
||||
<span><span style="display:inline-block;width:10px;height:9px;background:var(--color-accent-800);vertical-align:-1px"></span> DUT_* 实心 — 真实信号,计入版本结论</span>
|
||||
<span><span style="display:inline-block;width:10px;height:9px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.35) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);vertical-align:-1px"></span> INFRA_* 斜线 — 基础设施噪声,单独监控误报率</span>
|
||||
<span><span style="display:inline-block;width:10px;height:9px;background:var(--color-neutral-300);vertical-align:-1px"></span> SCRIPT 灰 — 脚本/参数问题</span>
|
||||
<span><span style="display:inline-block;width:10px;height:9px;border:1px dashed var(--color-neutral-500);vertical-align:-1px"></span> UNKNOWN 虚线 — 证据不足,等待分类</span>
|
||||
<span><span style="display:inline-block;width:10px;height:9px;background:#b03d2e;vertical-align:-1px"></span> 阻断 — 门禁级</span>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:0;gap:0;overflow:visible">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:grid;grid-template-columns:30px 92px 240px 1fr 120px 130px 150px 110px;gap:10px;align-items:center;padding:9px 14px;border-bottom:1px solid var(--color-divider);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--color-neutral-600)">
|
||||
<span></span><span>指纹</span><span>状态码</span><span>摘要</span><span>次数 · 14D</span><span>首次 / 最近</span><span>关联版本</span><span>状态</span>
|
||||
</div>
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="7">
|
||||
<div>
|
||||
<div onClick="{{ r.toggle }}" style="{{ r.rowStyle }}">
|
||||
<span style="{{ r.chevStyle }}">{{ r.chev }}</span>
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700">{{ r.id }}</span>
|
||||
<span><span style="{{ r.tagStyle }}">{{ r.code }}</span></span>
|
||||
<span style="font-size:13px">{{ r.summary }}<sc-if value="{{ r.blocking }}" hint-placeholder-val="{{ false }}"><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;background:#b03d2e;color:#f7f4f2;padding:1px 6px;margin-left:8px;vertical-align:1px">阻断</span></sc-if></span>
|
||||
<span style="display:flex;align-items:flex-end;gap:8px"><span style="font-family:var(--font-heading);font-weight:600;font-size:18px;line-height:1">{{ r.count }}</span><span style="display:flex;align-items:flex-end;gap:2px;height:16px">
|
||||
<sc-for list="{{ r.bars }}" as="b" hint-placeholder-count="7"><span style="{{ b.s }}"></span></sc-for>
|
||||
</span></span>
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--color-neutral-600);line-height:1.5">{{ r.first }}<br>{{ r.last }}</span>
|
||||
<span style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
<sc-for list="{{ r.vers }}" as="v" hint-placeholder-count="2"><span style="font-family:ui-monospace,Menlo,monospace;font-size:9.5px;border:1px solid var(--color-divider);padding:1px 6px;color:var(--color-neutral-700)">{{ v }}</span></sc-for>
|
||||
</span>
|
||||
<span><span style="{{ r.stTagStyle }}">{{ r.status }}</span></span>
|
||||
</div>
|
||||
<sc-if value="{{ r.open }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:grid;grid-template-columns:1.3fr 1fr 1fr;gap:16px;padding:14px 16px 16px 54px;background:var(--color-neutral-100);border-bottom:1px solid var(--color-divider)">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.1em;color:var(--color-neutral-600);margin-bottom:7px">指纹特征字段 · SIGNATURE</div>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:11px;line-height:1.9;color:var(--color-neutral-800)">
|
||||
<sc-for list="{{ r.feats }}" as="ft" hint-placeholder-count="4"><div style="display:flex;gap:10px"><span style="color:var(--color-neutral-500);min-width:88px">{{ ft.k }}</span><span>{{ ft.v }}</span></div></sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.1em;color:var(--color-neutral-600);margin-bottom:7px">关联运行 · {{ r.count }} 次</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px">
|
||||
<sc-for list="{{ r.runs }}" as="rn" hint-placeholder-count="3"><a href="Evidence.dc.html" style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;border:1px solid var(--color-accent-400);color:var(--color-accent-700);padding:3px 8px;text-decoration:none;width:fit-content">{{ rn }}</a></sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.1em;color:var(--color-neutral-600);margin-bottom:7px">最短复现 · REPRO</div>
|
||||
<div style="font-size:12px;line-height:1.6;border:1px solid var(--color-divider);padding:8px 10px;background:var(--color-bg)">{{ r.repro }}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" style="font-size:12px;padding:5px 12px">生成缺陷草稿</button>
|
||||
<button class="btn btn-secondary" style="font-size:12px;padding:5px 12px">创建复现任务</button>
|
||||
<button class="btn btn-secondary" style="font-size:12px;padding:5px 12px">合并…</button>
|
||||
<button class="btn btn-secondary" style="font-size:12px;padding:5px 12px">拆分</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;margin-top:12px;font-size:11px;color:var(--color-neutral-600)">
|
||||
<span>聚类由归一化结构字段计算签名,语义模型仅辅助合并建议</span>
|
||||
<span style="margin-left:auto">30 天:去重后 7 簇 ← 43 次原始失败 · 重复问题合并率 84%</span>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":880}}">
|
||||
class Component extends DCLogic {
|
||||
state = { cat: 'all', openId: 'FS-0091' };
|
||||
renderVals() {
|
||||
const mono = { fontFamily: 'ui-monospace,Menlo,monospace' };
|
||||
const tagOf = code => {
|
||||
if (code.startsWith('DUT')) return { ...mono, fontSize: '10px', padding: '2px 8px', background: 'var(--color-accent-800)', color: '#f2f2f3' };
|
||||
if (code.startsWith('INFRA')) return { ...mono, fontSize: '10px', padding: '2px 8px', background: 'repeating-linear-gradient(135deg,rgba(29,31,32,.12) 0 2px,transparent 2px 5px)', border: '1px solid var(--color-divider)', color: 'var(--color-neutral-800)' };
|
||||
if (code === 'UNKNOWN') return { ...mono, fontSize: '10px', padding: '2px 8px', border: '1px dashed var(--color-neutral-500)', color: 'var(--color-neutral-700)' };
|
||||
return { ...mono, fontSize: '10px', padding: '2px 8px', background: 'var(--color-neutral-200)', color: 'var(--color-neutral-800)' };
|
||||
};
|
||||
const stOf = st => {
|
||||
const base = { fontSize: '10.5px', padding: '2px 9px', letterSpacing: '.02em' };
|
||||
if (st === '已建单') return { ...base, background: 'var(--color-accent-100)', color: 'var(--color-accent-800)' };
|
||||
if (st === '待建单') return { ...base, border: '1px solid #b03d2e', color: '#8a2e22' };
|
||||
if (st === '待分类') return { ...base, border: '1px dashed var(--color-neutral-500)', color: 'var(--color-neutral-700)' };
|
||||
if (st === '已修复') return { ...base, border: '1px solid var(--color-accent-400)', color: 'var(--color-accent-700)' };
|
||||
return { ...base, background: 'var(--color-neutral-200)', color: 'var(--color-neutral-800)' };
|
||||
};
|
||||
const C = [
|
||||
{ id: 'FS-0074', cat: 'dut', code: 'DUT_DATA_FAILURE', summary: '4k 混合负载后 LBA 区间校验失败', blocking: true, count: 2, hist: [0,0,0,0,0,1,1], first: '首 07-26', last: '近 昨日', vers: ['3.3.0-rc2'], status: '待建单',
|
||||
feats: [{k:'step',v:'data.verify_hash'},{k:'lba_range',v:'0x2E41_0000–0x2E41_3FFF'},{k:'pattern',v:'写入后休眠,唤醒读校验 mismatch'},{k:'smart',v:'无介质错误计数增长'},{k:'data_loss',v:'true · 门禁级'}],
|
||||
runs: ['run-20260726-0007 · cycle 41', 'run-20260726-0009 · cycle 17'], repro: '写入 32G 校验集 → S4 → 唤醒读校验;固定 SAMPLE-002,预计 45min ×10 循环' },
|
||||
{ id: 'FS-0091', cat: 'dut', code: 'DUT_ENUMERATION_FAILURE', summary: 'S4 唤醒后掉盘,链路未训练', blocking: false, count: 12, hist: [0,1,1,2,2,3,3], first: '首 07-18', last: '近 昨日', vers: ['3.3.0-rc1', '3.3.0-rc2'], status: '已建单',
|
||||
feats: [{k:'step',v:'dut.verify_enumeration'},{k:'kernel',v:'nvme nvme0: Device not ready; aborting reset'},{k:'power_state',v:'S4 resume · RTC 唤醒'},{k:'env',v:'HOST-L02 · BIOS F26b · 42–47°C'},{k:'recovery',v:'L3 Reset 后 100% 恢复'}],
|
||||
runs: ['run-20260726-0007 · cycle 41', 'run-20260725-0021 · cycle 63', 'run-20260722-0004 · cycle 12', '… 另 9 次'], repro: 'S4 循环 ×20 · 固定 WS-03 + SAMPLE-002 · 复现率 ~15% · 预计 3.5h' },
|
||||
{ id: 'FS-0071', cat: 'dut', code: 'DUT_PERFORMANCE_REGRESSION', summary: '高温限速触发,4k 写长尾退化', blocking: false, count: 9, hist: [1,0,1,1,2,2,2], first: '首 07-12', last: '近 今日', vers: ['3.2.1', '3.3.0-rc2'], status: '观察',
|
||||
feats: [{k:'step',v:'io.run_profile 4k_mixed'},{k:'threshold',v:'P99 > 基线 +8%'},{k:'temp_window',v:'46°C 以上触发'},{k:'freq',v:'A:4 → B:9 · 偶发转频发'}],
|
||||
runs: ['run-20260727-0001 · cycle 33', 'run-20260726-0007 · cycle 08'], repro: '恒温箱 45°C · 4k_mixed 30min ×5 · 观察限速计数器' },
|
||||
{ id: 'FS-0088', cat: 'infra', code: 'INFRA_HOST_OS_FAILURE', summary: 'Windows 蓝屏 DRIVER_POWER_STATE_FAILURE', blocking: false, count: 5, hist: [1,1,0,1,1,0,1], first: '首 07-08', last: '近 07-25', vers: ['跨版本'], status: '处理中',
|
||||
feats: [{k:'bugcheck',v:'0x9F · 冷启动循环期间'},{k:'scope',v:'跨 3 工位,与固件版本无关'},{k:'suspect',v:'主机 NIC 驱动电源管理'},{k:'guard',v:'不计入 DUT 结论'}],
|
||||
runs: ['run-20260725-0018 · cycle 88', 'run-20260721-0002 · cycle 41'], repro: 'golden image 更新 NIC 驱动后回归 ×50 冷启动' },
|
||||
{ id: 'FS-0059', cat: 'infra', code: 'INFRA_NETWORK_FAILURE', summary: '交换机端口抖动导致 Agent 误报失联', blocking: false, count: 3, hist: [1,1,1,0,0,0,0], first: '首 06-30', last: '近 07-21', vers: ['—'], status: '已修复',
|
||||
feats: [{k:'signal',v:'OOB 心跳正常 + Agent 断连'},{k:'fix',v:'更换 SW-02 端口 6 网线'},{k:'lesson',v:'已加入判定规则:双路径交叉验证'}],
|
||||
runs: ['run-20260721-0006'], repro: '—' },
|
||||
{ id: 'FS-0067', cat: 'script', code: 'TEST_SCRIPT_FAILURE', summary: 'fio 3.36 参数不兼容私有 profile', blocking: false, count: 9, hist: [2,2,1,1,1,1,1], first: '首 07-02', last: '近 今日', vers: ['—'], status: '处理中',
|
||||
feats: [{k:'stderr',v:'unrecognized option --write_hist_log'},{k:'owner',v:'测试平台组'},{k:'workaround',v:'连接器已降级参数集'}],
|
||||
runs: ['run-20260727-0002 · precheck'], repro: 'fio --version 校验加入 precheck,连接器 healthcheck 拦截' },
|
||||
{ id: 'FS-0102', cat: 'unknown', code: 'UNKNOWN', summary: 'verify_hash 超时但各信号正常', blocking: false, count: 1, hist: [0,0,0,0,0,0,1], first: '首 昨日', last: '近 昨日', vers: ['3.2.1'], status: '待分类',
|
||||
feats: [{k:'signal',v:'主机/DUT/网络均正常'},{k:'evidence_gap',v:'缺 blktrace · 已加入下次采集'},{k:'action',v:'等待工程师分类 → 反哺规则'}],
|
||||
runs: ['run-20260726-0011 · cycle 12'], repro: '纳入观察名单 · 同参数复跑 ×5 后再判' }
|
||||
];
|
||||
const cat = this.state.cat;
|
||||
const rows = C.filter(c => cat === 'all' || c.cat === cat).map(c => {
|
||||
const open = this.state.openId === c.id;
|
||||
const maxH = Math.max(...c.hist, 1);
|
||||
return {
|
||||
...c, open,
|
||||
toggle: () => this.setState({ openId: open ? null : c.id }),
|
||||
chev: open ? '▾' : '▸',
|
||||
chevStyle: { color: 'var(--color-accent-700)', fontSize: '11px' },
|
||||
tagStyle: tagOf(c.code), stTagStyle: stOf(c.status),
|
||||
bars: c.hist.map(h => ({ s: { width: '5px', height: Math.max(2, (h / maxH) * 16) + 'px', background: h ? (c.cat === 'infra' ? 'repeating-linear-gradient(135deg,rgba(29,31,32,.4) 0 1px,transparent 1px 3px)' : c.cat === 'dut' ? 'var(--color-accent-700)' : 'var(--color-neutral-400)') : 'var(--color-neutral-200)', border: h && c.cat === 'infra' ? '1px solid var(--color-divider)' : 'none' } })),
|
||||
rowStyle: {
|
||||
display: 'grid', gridTemplateColumns: '30px 92px 240px 1fr 120px 130px 150px 110px', gap: '10px', alignItems: 'center',
|
||||
padding: '11px 14px', cursor: 'pointer', transition: 'background .15s ease',
|
||||
borderBottom: '1px solid rgba(29,31,32,.08)',
|
||||
background: open ? 'var(--color-accent-100)' : 'transparent',
|
||||
borderLeft: c.blocking ? '3px solid #b03d2e' : '3px solid transparent'
|
||||
}
|
||||
};
|
||||
});
|
||||
const set = c => () => this.setState({ cat: c });
|
||||
return {
|
||||
rows,
|
||||
cAll: cat === 'all', cDut: cat === 'dut', cInfra: cat === 'infra', cScript: cat === 'script', cUnk: cat === 'unknown',
|
||||
setAll: set('all'), setDut: set('dut'), setInfra: set('infra'), setScript: set('script'), setUnk: set('unknown')
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,450 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
@keyframes omPulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||
@keyframes omBlink{0%,49%{opacity:1}50%,100%{opacity:.15}}
|
||||
</style>
|
||||
</helmet>
|
||||
<dc-import name="ConsoleNav" active="live" hint-size="100%,92px"></dc-import>
|
||||
<div data-screen-label="实时运行" style="max-width:1400px;margin:0 auto;padding:22px 24px 64px;font-family:var(--font-body);animation:omIn .5s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.18em;color:var(--color-accent-700);font-weight:500">03 · LIVE RUN</div>
|
||||
<h2 style="margin:2px 0 0;font-size:30px">实时运行</h2>
|
||||
<div class="text-muted" style="font-size:12.5px;margin-top:2px">{{ hostName }} · 只在必须时人工接管</div>
|
||||
</div>
|
||||
<span style="margin-left:auto"></span>
|
||||
<button class="btn btn-secondary" onClick="{{ startDemo }}" disabled="{{ demoBusy }}" style="gap:7px">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
|
||||
演示:注入主机失联
|
||||
</button>
|
||||
<div class="seg" style="background:var(--color-bg)">
|
||||
<label class="seg-opt"><input type="radio" name="lrv" checked="{{ vA }}" onChange="{{ setA }}"><span>A · 控制室三栏</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="lrv" checked="{{ vB }}" onChange="{{ setB }}"><span>B · 时间线泳道</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;flex-direction:row;align-items:center;gap:20px;padding:12px 16px;margin-bottom:18px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:14px;font-weight:700">{{ runId }}</div>
|
||||
<div style="font-size:11.5px;color:var(--color-neutral-600)">fw_ab_powerstate_regression v3 · 已审核模板</div>
|
||||
</div>
|
||||
<span style="width:1px;height:30px;background:var(--color-divider)"></span>
|
||||
<div style="font-size:12px;line-height:1.5;color:var(--color-neutral-800)"><span class="text-muted">固件</span> {{ firmwareLabel }}<br><span class="text-muted">DUT</span> {{ dutLabel }}</div>
|
||||
<span style="width:1px;height:30px;background:var(--color-divider)"></span>
|
||||
<div style="font-size:12px;line-height:1.5;color:var(--color-neutral-800)"><span class="text-muted">开始</span> {{ startText }}<br><span class="text-muted">检查点</span> {{ checkpointText }}</div>
|
||||
<span style="width:1px;height:30px;background:var(--color-divider)"></span>
|
||||
<div style="flex:1;min-width:180px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--color-neutral-700);margin-bottom:4px"><span>轮次 {{ cycle }} / {{ target }}</span><span>由控制平面实时投影</span></div>
|
||||
<div style="height:6px;border:1px solid var(--color-divider);position:relative"><div style="{{ progStyle }}"></div></div>
|
||||
</div>
|
||||
<span style="{{ badgeStyle }}">{{ badgeText }}</span>
|
||||
<button class="btn btn-secondary" onClick="{{ togglePause }}" disabled="{{ pauseDisabled }}" style="font-size:12.5px">{{ pauseLabel }}</button>
|
||||
<button class="btn btn-secondary" disabled="{{ true }}" style="font-size:12.5px" title="真实 Agent 接入后开放">人工接管</button>
|
||||
<button onClick="{{ estop }}" disabled="{{ stopDisabled }}" style="display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid #b03d2e;color:#8a2e22;padding:7px 12px;font-family:var(--font-heading);font-weight:600;font-size:12.5px;letter-spacing:.05em;cursor:pointer" style-hover="background:#f7e9e6">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon></svg>
|
||||
停止本工位
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ estopped }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;align-items:center;gap:12px;background:#b03d2e;color:#f7f4f2;padding:10px 16px;margin-bottom:18px;font-size:13px">
|
||||
<strong style="font-family:var(--font-heading);letter-spacing:.05em">{{ hostName }} 已安全停止</strong>
|
||||
<span style="opacity:.85">任务已安全停止,现场与检查点已保留 · 已写入不可变审计事件</span>
|
||||
<button onClick="{{ resumeEstop }}" style="margin-left:auto;background:transparent;border:1px solid rgba(247,244,242,.55);color:#f7f4f2;padding:4px 12px;font-family:var(--font-heading);font-size:12px;cursor:pointer" style-hover="background:rgba(247,244,242,.14)">新建演示任务</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ vA }}" hint-placeholder-val="{{ true }}">
|
||||
<div data-screen-label="实时运行 · 变体A 控制室" style="display:grid;grid-template-columns:300px 1fr 300px;gap:18px;align-items:start">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:4px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0 0 8px;color:var(--color-neutral-700)">步骤执行器 · WORKFLOW</h6>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:6px 0;font-size:12.5px"><span style="width:16px;height:16px;border:1px solid var(--color-accent-400);color:var(--color-accent-700);display:inline-flex;align-items:center;justify-content:center;font-size:10px">✓</span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px">lab.precheck</span><span style="margin-left:auto;font-size:10px;color:var(--color-neutral-500)">09:12</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:6px 0;font-size:12.5px"><span style="width:16px;height:16px;border:1px solid var(--color-accent-400);color:var(--color-accent-700);display:inline-flex;align-items:center;justify-content:center;font-size:10px">✓</span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px">vendor.flash_firmware B</span><span style="margin-left:auto;font-size:10px;color:var(--color-neutral-500)">09:14</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:6px 0;font-size:12.5px"><span style="width:16px;height:16px;border:1px solid var(--color-accent-400);color:var(--color-accent-700);display:inline-flex;align-items:center;justify-content:center;font-size:10px">✓</span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px">host.reboot · 版本确认</span><span style="margin-left:auto;font-size:10px;color:var(--color-neutral-500)">09:18</span></div>
|
||||
<div style="border:1px solid var(--color-divider);padding:8px 10px;margin:6px 0 2px">
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:11px;color:var(--color-neutral-700);margin-bottom:6px"><span style="font-family:ui-monospace,Menlo,monospace">loop · repeat {{ target }}</span><span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;color:var(--color-accent-700)">cycle {{ cycle }}</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:5px 0;font-size:12px"><span style="width:14px;height:14px;border:1px solid var(--color-accent-400);color:var(--color-accent-700);display:inline-flex;align-items:center;justify-content:center;font-size:9px">✓</span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11px">nvme.collect_baseline</span></div>
|
||||
<div style="{{ ioRowStyle }}"><span style="{{ ioDotStyle }}"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11px;font-weight:700">io.run_profile 4k_mixed</span><span style="margin-left:auto;font-size:10px">{{ ioNote }}</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:5px 0;font-size:12px;color:var(--color-neutral-500)"><span style="width:14px;height:14px;border:1px dashed var(--color-divider)"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11px">host.hibernate</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:5px 0;font-size:12px;color:var(--color-neutral-500)"><span style="width:14px;height:14px;border:1px dashed var(--color-divider)"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11px">host.wait_resume</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:5px 0;font-size:12px;color:var(--color-neutral-500)"><span style="width:14px;height:14px;border:1px dashed var(--color-divider)"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11px">dut.verify_enumeration</span></div>
|
||||
<div style="display:flex;gap:9px;align-items:center;padding:5px 0;font-size:12px;color:var(--color-neutral-500)"><span style="width:14px;height:14px;border:1px dashed var(--color-divider)"></span><span style="font-family:ui-monospace,Menlo,monospace;font-size:11px">data.verify_hash</span></div>
|
||||
</div>
|
||||
<div style="border:1px dashed #b03d2e;padding:8px 10px;margin-top:6px">
|
||||
<div style="font-size:10px;letter-spacing:.1em;color:#8a2e22;margin-bottom:5px">ON_FAILURE</div>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--color-neutral-700);line-height:1.8">evidence.capture_all<br>recovery.escalate<br>failure.classify</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:10.5px;color:var(--color-neutral-600);margin-top:8px;font-family:ui-monospace,Menlo,monospace"><span>PASS 66</span><span>FAIL 0</span><span>自动恢复 1</span><span>介入 0</span></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:0;gap:0;overflow:hidden">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--color-divider)">
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">实时日志流 · EVENT LOG</h6>
|
||||
<span style="margin-left:auto;display:inline-flex;gap:6px;font-size:10px;font-family:ui-monospace,Menlo,monospace">
|
||||
<span style="padding:2px 8px;background:var(--color-accent);color:#f2f2f3">ALL</span>
|
||||
<span style="padding:2px 8px;border:1px solid var(--color-divider);color:var(--color-neutral-600)">CMD</span>
|
||||
<span style="padding:2px 8px;border:1px solid var(--color-divider);color:var(--color-neutral-600)">OOB</span>
|
||||
<span style="padding:2px 8px;border:1px solid var(--color-divider);color:var(--color-neutral-600)">WARN</span>
|
||||
</span>
|
||||
</div>
|
||||
<div ref="{{ logRef }}" style="height:330px;overflow-y:auto;padding:10px 14px;background:var(--color-neutral-100);font-family:ui-monospace,Menlo,monospace;font-size:11px;line-height:1.75">
|
||||
<sc-for list="{{ logs }}" as="ln" hint-placeholder-count="8">
|
||||
<div style="animation:omIn .3s ease both;display:flex;gap:10px"><span style="color:var(--color-neutral-500);white-space:nowrap">{{ ln.t }}</span><span style="{{ ln.laneStyle }}">{{ ln.lane }}</span><span style="{{ ln.textStyle }}">{{ ln.text }}</span></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1.15fr 1fr;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><h6 style="margin:0;color:var(--color-neutral-700)">HDMI 采集画面</h6><span style="margin-left:auto;font-size:10px;font-family:ui-monospace,Menlo,monospace;color:{{ hdmiColor }}">{{ hdmiNote }}</span></div>
|
||||
<div style="height:186px;border:1px solid var(--color-divider)">
|
||||
<x-import component-from-global-scope="image-slot" from="./image-slot.js" id="livehdmi" shape="rect" hint-size="100%,186px" placeholder="HDMI 采集帧 — 拖入主机画面截图"></x-import>
|
||||
</div>
|
||||
<div style="font-size:10.5px;color:var(--color-neutral-600);font-family:ui-monospace,Menlo,monospace">采集卡 CAP-01 · 1080p@5fps · OS 未启动/蓝屏时仍可留证</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">遥测 · TELEMETRY</h6>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:11px">
|
||||
<div style="border:1px solid var(--color-divider);padding:8px 10px"><div class="text-muted" style="font-size:10px;letter-spacing:.08em">HOST 心跳</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:700;color:{{ hbColor }};margin-top:2px;animation:{{ hbAnim }}">{{ hbText }}</div></div>
|
||||
<div style="border:1px solid var(--color-divider);padding:8px 10px"><div class="text-muted" style="font-size:10px;letter-spacing:.08em">DUT 温度</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:700;margin-top:2px">{{ temp }}°C</div></div>
|
||||
<div style="border:1px solid var(--color-divider);padding:8px 10px"><div class="text-muted" style="font-size:10px;letter-spacing:.08em">设备枚举</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700;color:var(--color-accent-700);margin-top:4px">nvme0n1 · Gen4 x4</div></div>
|
||||
<div style="border:1px solid var(--color-divider);padding:8px 10px"><div class="text-muted" style="font-size:10px;letter-spacing:.08em">供电 · OOB</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700;margin-top:4px">AC ON · ATX ON</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--color-neutral-600);margin-bottom:3px"><span>温度 30min</span><span>阈值 55°C</span></div>
|
||||
<svg width="100%" height="36" viewBox="0 0 240 36" preserveAspectRatio="none"><line x1="0" y1="8" x2="240" y2="8" stroke="rgba(29,31,32,.14)" stroke-dasharray="3 3"></line><polyline stroke-linejoin="round" stroke-linecap="round" points="0,26 20,25 40,27 60,24 80,22 100,23 120,20 140,21 160,19 180,22 200,20 220,21 240,20" fill="none" stroke="#5980a6" stroke-width="1.5"></polyline></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px;border-color:{{ ladderBorder }}">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><h6 style="margin:0;color:var(--color-neutral-700)">分级恢复阶梯 · ESCALATION</h6><span style="margin-left:auto;font-size:10px;font-family:ui-monospace,Menlo,monospace;color:var(--color-neutral-600)">esc-default-v2</span></div>
|
||||
<div style="display:flex;flex-direction:column">
|
||||
<sc-for list="{{ ladder }}" as="lv" hint-placeholder-count="5">
|
||||
<div style="{{ lv.rowStyle }}">
|
||||
<span style="{{ lv.numStyle }}">{{ lv.num }}</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:12.5px;font-weight:500">{{ lv.name }}</div>
|
||||
<div style="font-size:10.5px;color:var(--color-neutral-600)">{{ lv.desc }}</div>
|
||||
</div>
|
||||
<span style="{{ lv.stateStyle }}">{{ lv.state }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="font-size:10.5px;color:var(--color-neutral-600);border-top:1px solid var(--color-divider);padding-top:8px">恢复前先取证 · 多次失败进入安全停止,禁止无限重启</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">本轮关键指标 · CYCLE {{ cycle }}</h6>
|
||||
<div style="display:flex;flex-direction:column;font-family:ui-monospace,Menlo,monospace;font-size:11px">
|
||||
<div style="display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-600)">4k_mixed IOPS</span><span>218,430</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-600)">P99 延迟</span><span>412 µs</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-600)">唤醒耗时</span><span>11.2 s</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid rgba(29,31,32,.08)"><span style="color:var(--color-neutral-600)">SMART 温度峰值</span><span>46°C</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:5px 0"><span style="color:var(--color-neutral-600)">校验哈希</span><span style="color:var(--color-accent-700)">一致 ✓</span></div>
|
||||
</div>
|
||||
<a href="Evidence.dc.html" style="font-size:11.5px">打开本任务证据 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ vB }}" hint-placeholder-val="{{ false }}">
|
||||
<div data-screen-label="实时运行 · 变体B 时间线泳道">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px;margin-bottom:16px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><h6 style="margin:0;color:var(--color-neutral-700)">执行时间线 · 14:20 → NOW</h6><span style="margin-left:auto;font-size:10.5px;font-family:ui-monospace,Menlo,monospace;color:var(--color-neutral-600)">每格 2 分钟 · cycle 66–67</span></div>
|
||||
<div style="display:grid;grid-template-columns:64px 1fr;row-gap:6px;font-family:ui-monospace,Menlo,monospace;font-size:10px">
|
||||
<div style="color:var(--color-neutral-600);padding-top:5px">TEST</div>
|
||||
<div style="position:relative;height:26px;border:1px solid var(--color-divider);background:repeating-linear-gradient(90deg,transparent 0 calc(100%/9 - 1px),rgba(29,31,32,.07) calc(100%/9 - 1px) calc(100%/9))">
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:2%;width:24%;background:var(--color-accent-200);border:1px solid var(--color-accent-400);color:var(--color-accent-800);display:flex;align-items:center;padding:0 6px;overflow:hidden;white-space:nowrap">io 4k_mixed</div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:27%;width:9%;background:var(--color-accent-200);border:1px solid var(--color-accent-400);color:var(--color-accent-800);display:flex;align-items:center;padding:0 6px;overflow:hidden;white-space:nowrap">hib</div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:37%;width:7%;background:var(--color-accent-200);border:1px solid var(--color-accent-400);color:var(--color-accent-800);display:flex;align-items:center;padding:0 6px;overflow:hidden;white-space:nowrap">res</div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:45%;width:6%;background:var(--color-accent-200);border:1px solid var(--color-accent-400);color:var(--color-accent-800);display:flex;align-items:center;padding:0 4px;overflow:hidden;white-space:nowrap">枚举</div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:52%;width:10%;background:var(--color-accent-200);border:1px solid var(--color-accent-400);color:var(--color-accent-800);display:flex;align-items:center;padding:0 6px;overflow:hidden;white-space:nowrap">verify</div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:64%;width:26%;background-color:var(--color-accent);background-image:repeating-linear-gradient(90deg,rgba(242,242,243,.3) 0 6px,transparent 6px 14px);animation:omScan 1.1s linear infinite;border:1px solid var(--color-accent-700);color:#f2f2f3;display:flex;align-items:center;padding:0 6px;overflow:hidden;white-space:nowrap;animation:omPulse 2s infinite">cycle 67 · io 4k_mixed RUNNING</div>
|
||||
</div>
|
||||
<div style="color:var(--color-neutral-600);padding-top:5px">HOST</div>
|
||||
<div style="position:relative;height:26px;border:1px solid var(--color-divider)">
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:2%;width:24%;border:1px solid var(--color-divider);color:var(--color-neutral-700);display:flex;align-items:center;padding:0 6px;white-space:nowrap;overflow:hidden">ONLINE · hb 1-3s</div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:27%;width:17%;background:repeating-linear-gradient(135deg,rgba(29,31,32,.14) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);color:var(--color-neutral-700);display:flex;align-items:center;padding:0 6px;white-space:nowrap;overflow:hidden">S4 休眠</div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:45%;width:45%;border:1px solid var(--color-divider);color:var(--color-neutral-700);display:flex;align-items:center;padding:0 6px;white-space:nowrap;overflow:hidden">ONLINE · resume 11.2s</div>
|
||||
</div>
|
||||
<div style="color:var(--color-neutral-600);padding-top:5px">DUT</div>
|
||||
<div style="position:relative;height:26px;border:1px solid var(--color-divider)">
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:2%;width:42%;border:1px solid var(--color-accent-400);color:var(--color-accent-800);display:flex;align-items:center;padding:0 6px;white-space:nowrap;overflow:hidden">nvme0n1 Gen4 x4 · 42-46°C</div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:45%;width:3%;background:var(--color-accent-800)"></div>
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:49%;width:41%;border:1px solid var(--color-accent-400);color:var(--color-accent-800);display:flex;align-items:center;padding:0 6px;white-space:nowrap;overflow:hidden">重新枚举 1.8s · SMART 正常</div>
|
||||
</div>
|
||||
<div style="color:var(--color-neutral-600);padding-top:5px">OOB</div>
|
||||
<div style="position:relative;height:26px;border:1px solid var(--color-divider)">
|
||||
<div style="position:absolute;top:4px;bottom:4px;left:2%;width:88%;border:1px dashed var(--color-divider);color:var(--color-neutral-500);display:flex;align-items:center;padding:0 6px;white-space:nowrap;overflow:hidden">待命 · 心跳监测中 · 无供电动作</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:relative;height:0"><div style="position:absolute;right:9.5%;top:-118px;width:1px;height:118px;background:#b03d2e;animation:omBlink 1.2s infinite"></div></div>
|
||||
<div style="display:flex;gap:14px;font-size:10px;color:var(--color-neutral-600)"><span><span style="display:inline-block;width:10px;height:8px;background:var(--color-accent-200);border:1px solid var(--color-accent-400);vertical-align:-1px"></span> 测试步骤</span><span><span style="display:inline-block;width:10px;height:8px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.14) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);vertical-align:-1px"></span> 电源状态</span><span><span style="display:inline-block;width:10px;height:8px;background:var(--color-accent-800);vertical-align:-1px"></span> 链路事件</span><span style="color:#8a2e22">│ 播放头 NOW</span></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 300px;gap:16px;align-items:start">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:0;gap:0">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="padding:10px 14px;border-bottom:1px solid var(--color-divider)"><h6 style="margin:0;color:var(--color-neutral-700)">日志尾部 · TAIL</h6></div>
|
||||
<div ref="{{ logRefB }}" style="height:260px;overflow-y:auto;padding:10px 14px;background:var(--color-neutral-100);font-family:ui-monospace,Menlo,monospace;font-size:11px;line-height:1.75">
|
||||
<sc-for list="{{ logs }}" as="ln" hint-placeholder-count="8">
|
||||
<div style="animation:omIn .3s ease both;display:flex;gap:10px"><span style="color:var(--color-neutral-500);white-space:nowrap">{{ ln.t }}</span><span style="{{ ln.laneStyle }}">{{ ln.lane }}</span><span style="{{ ln.textStyle }}">{{ ln.text }}</span></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px;gap:10px;border-color:{{ ladderBorder }}">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">恢复阶梯</h6>
|
||||
<div style="display:flex;flex-direction:column">
|
||||
<sc-for list="{{ ladder }}" as="lv" hint-placeholder-count="5">
|
||||
<div style="{{ lv.rowStyle }}">
|
||||
<span style="{{ lv.numStyle }}">{{ lv.num }}</span>
|
||||
<div style="flex:1"><div style="font-size:12px;font-weight:500">{{ lv.name }}</div></div>
|
||||
<span style="{{ lv.stateStyle }}">{{ lv.state }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":950},"defaultVariant":{"editor":"enum","options":["a","b"],"default":"a","tsType":"string","section":"布局"}}">
|
||||
class Component extends DCLogic {
|
||||
state = {
|
||||
variant: null, phase: 'running', runState: 'IDLE', estopped: false, demoBusy: false,
|
||||
runId: '', hostName: 'WS-05', dutLabel: 'NV-E1T92', firmwareLabel: '候选固件',
|
||||
startText: '尚未开始', checkpointText: '尚无', cycle: 0, target: 8, progress: 0, hbAge: 2,
|
||||
logs: [
|
||||
{ t: '14:20:44', lane: 'SYS', tone: 'mut', text: 'cycle 66 完成 · 检查点已写入 (state_hash 7c1f…9d02)' },
|
||||
{ t: '14:21:52', lane: 'TEST', tone: 'ok', text: 'cycle 67 开始 · nvme.collect_baseline PASS' },
|
||||
{ t: '14:22:10', lane: 'CMD', tone: 'mut', text: 'fio --profile 4k_mixed --runtime 300 --dut /dev/nvme0n1' },
|
||||
{ t: '14:25:03', lane: 'TEST', tone: 'ok', text: 'hibernate → resume 成功 · 唤醒 11.2s · 唤醒源 RTC' },
|
||||
{ t: '14:25:11', lane: 'DUT', tone: 'ok', text: 'verify_enumeration PASS · nvme0n1 Gen4 x4 · 1.8s' },
|
||||
{ t: '14:28:51', lane: 'TEST', tone: 'ok', text: 'data.verify_hash PASS · 128 files · sha256 一致' },
|
||||
{ t: '14:29:02', lane: 'CMD', tone: 'mut', text: 'io.run_profile 4k_mixed 启动 · duration 300s · QD32' },
|
||||
{ t: '14:30:15', lane: 'HB', tone: 'mut', text: 'Agent 心跳正常 · CPU 34% · MEM 41% · 磁盘余量 812G' }
|
||||
]
|
||||
};
|
||||
_log(lane, tone, text) {
|
||||
const t = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||
this.setState(s => ({ logs: [...s.logs, { t, lane, tone, text }] }));
|
||||
}
|
||||
_scroll() {
|
||||
for (const r of [this._ref, this._refB]) if (r && r.current) r.current.scrollTop = r.current.scrollHeight;
|
||||
}
|
||||
componentDidMount() {
|
||||
this._ref = React.createRef(); this._refB = React.createRef();
|
||||
this.forceUpdate();
|
||||
this._syncRun();
|
||||
this._poll = setInterval(() => this._syncRun(), 650);
|
||||
this._hb = setInterval(() => {
|
||||
if (this.state.phase === 'running' && !this.state.estopped) this.setState({ hbAge: (this.state.hbAge % 4) + 1 });
|
||||
}, 2000);
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this._hb); clearInterval(this._poll); }
|
||||
componentDidUpdate() { this._scroll(); }
|
||||
_lane(event) {
|
||||
if (event.kind.includes('RECOVERY')) return 'RCVR';
|
||||
if (event.kind.includes('HEARTBEAT')) return 'HB';
|
||||
if (event.source === 'oob') return 'OOB';
|
||||
if (event.kind.includes('EVIDENCE')) return 'EVID';
|
||||
if (event.kind.includes('LOOP')) return 'TEST';
|
||||
if (event.kind.includes('DUT')) return 'DUT';
|
||||
return 'SYS';
|
||||
}
|
||||
_tone(event) {
|
||||
if (event.severity === 'CRITICAL' || event.severity === 'ERROR') return 'bad';
|
||||
if (event.severity === 'WARNING') return 'warn';
|
||||
if (event.kind.includes('SUCCEEDED') || event.kind.includes('COMPLETED') || event.kind === 'SAFETY_APPROVED') return 'ok';
|
||||
return 'mut';
|
||||
}
|
||||
_phase(run, events) {
|
||||
if (run.state !== 'RECOVERING') return run.state === 'COMPLETED' ? 'completed' : 'running';
|
||||
const latest = [...events].reverse().find(e => e.kind === 'RECOVERY_STARTED');
|
||||
const level = latest && latest.payload ? latest.payload.level : '';
|
||||
if (level.startsWith('L3')) return 'l3';
|
||||
if (level.startsWith('L2')) return 'l2';
|
||||
if (level.startsWith('L1')) return 'l1';
|
||||
return 'lost';
|
||||
}
|
||||
async _syncRun() {
|
||||
const runId = this.state.runId || localStorage.getItem('flashops.activeRunId');
|
||||
if (!runId) return;
|
||||
try {
|
||||
const [runResponse, eventResponse] = await Promise.all([
|
||||
fetch('/api/v1/runs/' + encodeURIComponent(runId)),
|
||||
fetch('/api/v1/runs/' + encodeURIComponent(runId) + '/events')
|
||||
]);
|
||||
if (!runResponse.ok || !eventResponse.ok) return;
|
||||
const run = await runResponse.json();
|
||||
const events = await eventResponse.json();
|
||||
const logs = events.map(event => ({
|
||||
t: new Date(event.ts).toLocaleTimeString('zh-CN', { hour12: false }),
|
||||
lane: this._lane(event),
|
||||
tone: this._tone(event),
|
||||
text: '#' + event.seq + ' · ' + event.message
|
||||
}));
|
||||
this.setState({
|
||||
runId: run.id,
|
||||
hostName: (run.environment && run.environment.host) || 'WS-05',
|
||||
dutLabel: run.environment ? ((run.environment.dut_serial || '') + ' · ' + (run.environment.dut_model || '')).trim() : '未绑定',
|
||||
firmwareLabel: run.firmware_b_version ? ('FW_' + run.firmware_b_version + ' (B) 候选') : '候选固件',
|
||||
startText: run.started_at ? new Date(run.started_at).toLocaleTimeString('zh-CN', { hour12: false }) : '等待调度',
|
||||
checkpointText: run.checkpoint && run.checkpoint.loop_index ? ('cycle ' + run.checkpoint.loop_index) : '尚无',
|
||||
runState: run.state,
|
||||
cycle: run.loop_done,
|
||||
target: run.loop_target,
|
||||
progress: run.progress,
|
||||
phase: this._phase(run, events),
|
||||
estopped: run.state === 'ABORTED',
|
||||
demoBusy: !['COMPLETED', 'ABORTED', 'FROZEN', 'REJECTED'].includes(run.state),
|
||||
logs
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
async startDemo() {
|
||||
if (this.state.demoBusy || this.state.estopped) return;
|
||||
this.setState({ demoBusy: true, estopped: false, logs: [] });
|
||||
try {
|
||||
const response = await fetch('/api/v1/runs/demo', { method: 'POST' });
|
||||
if (!response.ok) throw new Error('创建演示任务失败');
|
||||
const run = await response.json();
|
||||
localStorage.setItem('flashops.activeRunId', run.id);
|
||||
this.setState({ runId: run.id, cycle: 0, target: run.loop_target, progress: 0 });
|
||||
this._syncRun();
|
||||
} catch (error) {
|
||||
this.setState({ demoBusy: false });
|
||||
this._log('SYS', 'bad', error.message || '控制平面不可用');
|
||||
}
|
||||
}
|
||||
async emergencyStop() {
|
||||
if (!this.state.runId || this.state.estopped) return;
|
||||
try {
|
||||
await fetch('/api/v1/runs/' + encodeURIComponent(this.state.runId) + '/emergency-stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actor: 'console-user', reason: '控制台紧急停止' })
|
||||
});
|
||||
this._syncRun();
|
||||
} catch (_) {}
|
||||
}
|
||||
async togglePause() {
|
||||
if (!this.state.runId || !['RUNNING', 'PAUSED'].includes(this.state.runState)) return;
|
||||
const action = this.state.runState === 'PAUSED' ? 'resume' : 'pause';
|
||||
try {
|
||||
await fetch('/api/v1/runs/' + encodeURIComponent(this.state.runId) + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actor: 'console-user', reason: action === 'pause' ? '控制台暂停' : '控制台继续' })
|
||||
});
|
||||
this._syncRun();
|
||||
} catch (_) {}
|
||||
}
|
||||
renderVals() {
|
||||
const s = this.state;
|
||||
const mono = { fontFamily: 'ui-monospace,Menlo,monospace' };
|
||||
const laneColors = { HB: '#8a2e22', OOB: 'var(--color-accent-800)', RCVR: '#8a2e22', TEST: 'var(--color-accent-700)', CMD: 'var(--color-neutral-600)', SYS: 'var(--color-neutral-600)', EVID: 'var(--color-neutral-600)', DUT: 'var(--color-accent-800)' };
|
||||
const toneColors = { ok: 'var(--color-accent-800)', bad: '#8a2e22', warn: '#8a2e22', mut: 'var(--color-neutral-700)', dut: 'var(--color-accent-800)' };
|
||||
const logs = s.logs.map(l => ({
|
||||
...l,
|
||||
laneStyle: { color: laneColors[l.lane] || 'var(--color-neutral-600)', whiteSpace: 'nowrap', minWidth: '38px' },
|
||||
textStyle: { color: toneColors[l.tone] || 'var(--color-text)' }
|
||||
}));
|
||||
const recovering = ['lost', 'l1', 'l2', 'l3', 'online'].includes(s.phase);
|
||||
const phaseIdx = { running: -1, lost: 0, l1: 0, l2: 1, l3: 2, online: 2 }[s.phase];
|
||||
const results = {
|
||||
running: [], lost: ['active'], l1: ['active'], l2: ['fail', 'active'], l3: ['fail', 'fail', 'active'], online: ['fail', 'fail', 'ok']
|
||||
}[s.phase] || [];
|
||||
const defs = [
|
||||
{ num: 'L1', name: 'Agent 软恢复', desc: '终止异常进程 · 清理资源' },
|
||||
{ num: 'L2', name: 'OS 通道重启', desc: 'SSH / WinRM 远程软重启' },
|
||||
{ num: 'L3', name: '带外 Reset', desc: '光耦短按主板 Reset' },
|
||||
{ num: 'L4', name: 'AC 电源循环', desc: '整机断电 · 安全间隔后上电' },
|
||||
{ num: 'L5', name: '安全停止', desc: '保留现场 · 禁止无限重启' }
|
||||
];
|
||||
const ladder = defs.map((d, i) => {
|
||||
const r = results[i];
|
||||
let state = '待命', stateStyle = { ...mono, fontSize: '10px', color: 'var(--color-neutral-500)' };
|
||||
let rowStyle = { display: 'flex', gap: '10px', alignItems: 'center', padding: '8px 6px', borderBottom: i < 4 ? '1px solid rgba(29,31,32,.08)' : 'none', transition: 'background .25s ease' };
|
||||
let numStyle = { ...mono, width: '26px', height: '22px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '10.5px', border: '1px dashed var(--color-divider)', color: 'var(--color-neutral-500)', transition: 'background .25s ease, border-color .25s ease, color .25s ease' };
|
||||
if (r === 'active') { state = '执行中'; stateStyle = { ...mono, fontSize: '10px', color: 'var(--color-accent-700)', animation: 'omPulse 1.2s infinite' }; numStyle = { ...numStyle, border: 'none', background: 'var(--color-accent-800)', color: '#f2f2f3' }; rowStyle = { ...rowStyle, background: 'var(--color-accent-100)' }; }
|
||||
if (r === 'fail') { state = '失败'; stateStyle = { ...mono, fontSize: '10px', color: '#8a2e22' }; numStyle = { ...numStyle, border: '1px solid #b03d2e', color: '#8a2e22' }; }
|
||||
if (r === 'ok') { state = '成功'; stateStyle = { ...mono, fontSize: '10px', color: 'var(--color-accent-700)' }; numStyle = { ...numStyle, border: 'none', background: 'var(--color-accent)', color: '#f2f2f3' }; }
|
||||
return { ...d, state, stateStyle, rowStyle, numStyle };
|
||||
});
|
||||
let badgeText = 'RUNNING', badgeStyle = { display: 'inline-flex', alignItems: 'center', gap: '6px', background: 'var(--color-accent)', color: '#f2f2f3', fontSize: '11px', letterSpacing: '.06em', padding: '5px 12px', fontFamily: 'var(--font-heading)', fontWeight: 600, transition: 'background .25s ease, color .25s ease' };
|
||||
if (recovering) { badgeText = 'RECOVERING'; badgeStyle = { ...badgeStyle, background: 'var(--color-accent-200)', color: 'var(--color-accent-800)', animation: 'omPulse 1.2s infinite' }; }
|
||||
if (s.runState === 'QUEUED' || s.runState === 'PREFLIGHT') { badgeText = s.runState; badgeStyle = { ...badgeStyle, background: 'var(--color-neutral-600)' }; }
|
||||
if (s.runState === 'COMPLETED') { badgeText = 'COMPLETED · PASS'; badgeStyle = { ...badgeStyle, background: 'var(--color-accent-700)' }; }
|
||||
if (s.runState === 'REJECTED' || s.runState === 'FROZEN') { badgeText = s.runState; badgeStyle = { ...badgeStyle, background: '#b03d2e' }; }
|
||||
if (s.estopped) { badgeText = 'EMERGENCY_STOP'; badgeStyle = { ...badgeStyle, background: '#b03d2e' }; }
|
||||
const hbLost = recovering && s.phase !== 'online';
|
||||
const vv = s.variant ?? (this.props.defaultVariant ?? 'a');
|
||||
return {
|
||||
vA: vv === 'a', vB: vv === 'b',
|
||||
setA: () => this.setState({ variant: 'a' }), setB: () => this.setState({ variant: 'b' }),
|
||||
startDemo: () => this.startDemo(), demoBusy: s.demoBusy,
|
||||
togglePause: () => this.togglePause(),
|
||||
pauseLabel: s.runState === 'PAUSED' ? '继续' : '暂停',
|
||||
pauseDisabled: !['RUNNING', 'PAUSED'].includes(s.runState),
|
||||
stopDisabled: !['QUEUED', 'PREFLIGHT', 'RUNNING', 'RECOVERING', 'PAUSED'].includes(s.runState),
|
||||
estop: () => this.emergencyStop(),
|
||||
resumeEstop: () => { this.setState({ estopped: false, demoBusy: false, runId: '' }, () => this.startDemo()); },
|
||||
estopped: s.estopped,
|
||||
runId: s.runId || '尚未创建真实任务',
|
||||
hostName: s.hostName,
|
||||
dutLabel: s.dutLabel,
|
||||
firmwareLabel: s.firmwareLabel,
|
||||
startText: s.startText,
|
||||
checkpointText: s.checkpointText,
|
||||
cycle: s.cycle,
|
||||
target: s.target,
|
||||
progStyle: { position: 'absolute', inset: 0, width: s.progress + '%', transition: 'width .4s ease', backgroundColor: s.estopped ? '#b03d2e' : 'var(--color-accent)', backgroundImage: s.estopped ? 'none' : 'repeating-linear-gradient(90deg,rgba(242,242,243,.3) 0 6px,transparent 6px 14px)', animation: s.estopped ? 'none' : 'omScan 1.1s linear infinite' },
|
||||
badgeText, badgeStyle,
|
||||
logs, logRef: this._ref, logRefB: this._refB, ladder,
|
||||
ladderBorder: recovering ? 'var(--color-accent-600)' : 'var(--color-divider)',
|
||||
hbText: s.estopped ? '已冻结' : (hbLost ? '丢失 ' + (s.phase === 'lost' ? '34s' : '2m+') : s.hbAge + 's 前'),
|
||||
hbColor: hbLost ? '#8a2e22' : 'var(--color-accent-700)',
|
||||
hbAnim: hbLost ? 'omPulse 1.2s infinite' : 'none',
|
||||
temp: '43.' + ((s.hbAge * 3) % 9),
|
||||
hdmiNote: hbLost ? '死机帧已冻结留证' : 'LIVE · 5fps',
|
||||
hdmiColor: hbLost ? '#8a2e22' : 'var(--color-accent-700)',
|
||||
ioRowStyle: { display: 'flex', gap: '9px', alignItems: 'center', padding: '5px 0', fontSize: '12px', color: recovering ? 'var(--color-neutral-500)' : 'var(--color-text)' },
|
||||
ioDotStyle: { width: '14px', height: '14px', background: recovering ? 'transparent' : 'var(--color-accent)', border: recovering ? '1px dashed var(--color-divider)' : 'none', animation: recovering ? 'none' : 'omPulse 1.6s infinite' },
|
||||
ioNote: recovering ? '冻结,等待恢复' : '178s / 300s'
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,264 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
@keyframes omPulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||
</style>
|
||||
</helmet>
|
||||
<dc-import name="ConsoleNav" active="tasks" hint-size="100%,92px"></dc-import>
|
||||
<div data-screen-label="任务中心" style="max-width:1400px;margin:0 auto;padding:22px 24px 64px;font-family:var(--font-body);animation:omIn .5s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;margin-bottom:18px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.18em;color:var(--color-accent-700);font-weight:500">02 · TASKS</div>
|
||||
<h2 style="margin:2px 0 0;font-size:30px">任务中心</h2>
|
||||
<div class="text-muted" style="font-size:12.5px;margin-top:2px">创建 · 队列 · 预约 · 破坏性任务必须绑定允许的 DUT 序列号</div>
|
||||
</div>
|
||||
<span style="margin-left:auto"></span>
|
||||
<span class="tag tag-neutral">运行 2</span>
|
||||
<span class="tag tag-neutral">队列 3</span>
|
||||
<span class="tag tag-neutral">今日完成 4</span>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:0;gap:0;margin-bottom:20px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 18px;border-bottom:1px solid var(--color-divider)">
|
||||
<h5 style="margin:0;letter-spacing:.03em">创建任务 · NEW RUN</h5>
|
||||
<span class="text-muted" style="font-size:11.5px">固件 A/B 循环回归</span>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">工作流签名 8e02…c4 · 已审核 v3</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1.15fr">
|
||||
|
||||
<div style="padding:16px 18px;border-right:1px solid var(--color-divider)">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:12px"><span style="font-family:var(--font-heading);font-weight:600;font-size:22px;color:var(--color-accent-700)">01</span><span style="font-size:10.5px;letter-spacing:.14em;color:var(--color-neutral-600)">资产绑定</span></div>
|
||||
<div class="field" style="margin-bottom:10px"><label>测试主机</label>
|
||||
<select class="input" style="font-size:13px"><option>WS-05 · HOST-A03 · Win11 22631</option><option>WS-04 · HOST-L01 · Ubuntu 22.04</option></select>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:10px"><label>DUT(被测设备)</label>
|
||||
<select class="input" style="font-size:13px"><option>SAMPLE-006 · NV-E1T92 · 1.92TB U.2</option><option>SAMPLE-003 · NV-E960 · 960GB M.2</option></select>
|
||||
</div>
|
||||
<div style="border:1px solid var(--color-divider);padding:8px 10px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;line-height:1.8;color:var(--color-neutral-700)">SN S6XNNA0T609 · 允许破坏性写入<br>P/E 磨损 12% · 生命周期 测试专用<br>带外 OOB-05 已配对 ✓</div>
|
||||
</div>
|
||||
|
||||
<div style="padding:16px 18px;border-right:1px solid var(--color-divider)">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:12px"><span style="font-family:var(--font-heading);font-weight:600;font-size:22px;color:var(--color-accent-700)">02</span><span style="font-size:10.5px;letter-spacing:.14em;color:var(--color-neutral-600)">固件 A / B</span></div>
|
||||
<div class="field" style="margin-bottom:10px"><label>固件 A · 基线</label>
|
||||
<select class="input" style="font-size:13px"><option>FW_3.2.1 · 已审批</option><option>FW_3.1.9 · 已审批</option></select>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:10px"><label>固件 B · 候选</label>
|
||||
<select class="input" style="font-size:13px"><option>FW_3.3.0-rc3 · 待审批</option><option>FW_3.3.0-rc2 · 已审批</option></select>
|
||||
</div>
|
||||
<div style="border:1px solid var(--color-divider);padding:8px 10px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;line-height:1.8;color:var(--color-neutral-700)">A sha256 4bd1…08ce ✓<br>B sha256 9f2c…41aa ✓<br><span style="color:#8a2e22">B 尚未审批 — 启动前需固件负责人批准</span></div>
|
||||
</div>
|
||||
|
||||
<div style="padding:16px 18px;border-right:1px solid var(--color-divider)">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:12px"><span style="font-family:var(--font-heading);font-weight:600;font-size:22px;color:var(--color-accent-700)">03</span><span style="font-size:10.5px;letter-spacing:.14em;color:var(--color-neutral-600)">模板与参数</span></div>
|
||||
<div class="field" style="margin-bottom:10px"><label>工作流模板(已审核)</label>
|
||||
<select class="input" style="font-size:13px"><option>fw_ab_powerstate_regression · v3</option><option>fw_coldboot_matrix · v2</option><option>fw_retention_72h · v1</option></select>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">
|
||||
<div class="field"><label>循环次数</label><input class="input" value="100" style="font-size:13px"></div>
|
||||
<div class="field"><label>检查点间隔</label><select class="input" style="font-size:13px"><option>每循环</option><option>每 5 循环</option></select></div>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:10px"><label>失败重试策略</label>
|
||||
<select class="input" style="font-size:13px"><option>esc-default-v2 · 分级恢复,破坏性步骤不自动重试</option><option>conservative · 任何失败即安全停止</option></select>
|
||||
</div>
|
||||
<div class="field"><label>报告接收人</label><input class="input" value="fw-team@corp, lab-lead@corp" style="font-size:13px"></div>
|
||||
</div>
|
||||
|
||||
<div style="padding:16px 18px;background:var(--color-neutral-100)">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:12px"><span style="font-family:var(--font-heading);font-weight:600;font-size:22px;color:var(--color-accent-700)">04</span><span style="font-size:10.5px;letter-spacing:.14em;color:var(--color-neutral-600)">安全与预检</span></div>
|
||||
<label style="display:flex;gap:9px;align-items:flex-start;font-size:12.5px;margin-bottom:8px;cursor:pointer"><input type="checkbox" checked style="accent-color:#5980a6;margin-top:2px"><span>允许破坏性写入(覆盖数据区)<br><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-600)">allowed_dut_serials: ["S6XNNA0T609"]</span></span></label>
|
||||
<label style="display:flex;gap:9px;align-items:center;font-size:12.5px;margin-bottom:12px;cursor:pointer"><input type="checkbox" checked style="accent-color:#5980a6"><span>系统盘保护已启用 · 非白名单序列号拒绝写入</span></label>
|
||||
<div style="border:1px solid var(--color-divider);background:var(--color-bg);padding:10px 12px;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;margin-bottom:6px"><span style="font-size:10px;letter-spacing:.12em;color:var(--color-neutral-600)">环境预检 · PRECHECK</span><span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:{{ pcColor }}">{{ pcNote }}</span></div>
|
||||
<div style="display:flex;flex-direction:column;font-family:ui-monospace,Menlo,monospace;font-size:11px">
|
||||
<sc-for list="{{ checks }}" as="ck" hint-placeholder-count="6">
|
||||
<div style="display:flex;gap:8px;align-items:center;padding:3.5px 0"><span style="{{ ck.iconStyle }}">{{ ck.icon }}</span><span style="{{ ck.txtStyle }}">{{ ck.name }}</span><span style="margin-left:auto;color:var(--color-neutral-500);font-size:10px">{{ ck.note }}</span></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<sc-if value="{{ started }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="border:1px solid var(--color-accent-600);background:var(--color-accent-100);color:var(--color-accent-800);padding:9px 12px;font-size:12.5px;margin-bottom:10px">✓ 已入队 <span style="font-family:ui-monospace,Menlo,monospace">{{ startedRun }}</span> · WS-05 · 正在执行 · <a href="LiveRun.dc.html">跟踪 →</a></div>
|
||||
</sc-if>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button class="btn btn-secondary" onClick="{{ runPrecheck }}" disabled="{{ pcBusy }}" style="flex:1">运行预检</button>
|
||||
<button class="btn btn-primary" onClick="{{ start }}" disabled="{{ startDisabled }}" style="flex:1.4;gap:7px">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>
|
||||
启动 / 预约任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><h6 style="margin:0;color:var(--color-neutral-700)">队列 · QUEUE 3</h6><span style="margin-left:auto;font-size:10.5px;color:var(--color-neutral-500)">调度策略:优先级 + 维护窗口避让</span></div>
|
||||
<table class="table">
|
||||
<thead><tr><th></th><th>任务</th><th>工位 / DUT</th><th>计划</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="tag tag-accent">P1</span></td>
|
||||
<td><div style="font-size:12.5px">fw_coldboot_matrix · 500 次冷启动</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">FW_3.3.0-rc2(B)</div></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-05 · SAMPLE-006</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">今日 22:00</td>
|
||||
<td><span class="tag tag-outline">已预约</span></td>
|
||||
<td><button class="btn btn-ghost" style="font-size:11.5px">取消</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag tag-neutral">P2</span></td>
|
||||
<td><div style="font-size:12.5px">fw_ab 从检查点续跑 · cycle 41→100</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">FW_3.3.0-rc2(B)</div></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-03 · SAMPLE-002</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">恢复后自动</td>
|
||||
<td><span style="font-size:10.5px;padding:3px 9px;background:var(--color-accent-200);color:var(--color-accent-800);animation:omPulse 1.4s infinite">等待恢复</span></td>
|
||||
<td><button class="btn btn-ghost" style="font-size:11.5px">改期</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag tag-neutral">P3</span></td>
|
||||
<td><div style="font-size:12.5px">incoming_qc_quick · 来料抽检 12 盘</div><div style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">批次 B2607-11</div></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">WS-04 · 批量</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">明日 06:00</td>
|
||||
<td><span class="tag tag-outline">已预约</span></td>
|
||||
<td><button class="btn btn-ghost" style="font-size:11.5px">改期</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center"><h6 style="margin:0;color:var(--color-neutral-700)">最近完成 · RECENT</h6><a href="Compare.dc.html" style="margin-left:auto;font-size:11.5px">全部报告 →</a></div>
|
||||
<table class="table">
|
||||
<thead><tr><th>RUN</th><th>模板</th><th>结果</th><th>循环</th><th>介入</th><th>报告</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px">run-…0726-0007</td>
|
||||
<td style="font-size:12.5px">fw_ab_powerstate</td>
|
||||
<td><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 8px;background:var(--color-accent-800);color:#f2f2f3">FAILED_DUT</span></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">97/100</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">1</td>
|
||||
<td><a href="Evidence.dc.html" style="font-size:12px">证据</a> · <a href="Compare.dc.html" style="font-size:12px">A/B</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px">run-…0726-0011</td>
|
||||
<td style="font-size:12.5px">fw_retention_72h 段2</td>
|
||||
<td><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 8px;border:1px dashed var(--color-neutral-500);color:var(--color-neutral-700)">UNKNOWN</span></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">50/50</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">0</td>
|
||||
<td><a href="Evidence.dc.html" style="font-size:12px">证据</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px">run-…0725-0003</td>
|
||||
<td style="font-size:12.5px">fw_ab_powerstate</td>
|
||||
<td><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 8px;border:1px solid var(--color-accent-400);color:var(--color-accent-700)">PASSED</span></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">100/100</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">0</td>
|
||||
<td><a href="Compare.dc.html" style="font-size:12px">A/B</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11.5px">run-…0725-0018</td>
|
||||
<td style="font-size:12.5px">fw_coldboot_matrix</td>
|
||||
<td><span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 8px;background:repeating-linear-gradient(135deg,rgba(29,31,32,.12) 0 2px,transparent 2px 5px);border:1px solid var(--color-divider);color:var(--color-neutral-800)">FAILED_INFRA</span></td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">88/100</td>
|
||||
<td style="font-family:ui-monospace,Menlo,monospace;font-size:11px">0</td>
|
||||
<td><a href="Evidence.dc.html" style="font-size:12px">证据</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":920}}">
|
||||
class Component extends DCLogic {
|
||||
state = { checks: Array(6).fill('idle'), pc: 'idle', started: false, startedRun: '', error: '' };
|
||||
_names = [
|
||||
{ name: 'DUT 序列号白名单绑定', note: 'S6XNNA0T609' },
|
||||
{ name: '系统盘 / 分区保护', note: '启动盘已排除' },
|
||||
{ name: '磁盘空间 · 日志目录', note: '812G 可用' },
|
||||
{ name: '带外控制器 OOB-05', note: 'Power/Reset/温度' },
|
||||
{ name: '工具链版本 · 许可证', note: 'fio 3.36 · nvme-cli 2.9.1' },
|
||||
{ name: '时间同步 · 网络', note: 'NTP 偏差 4ms' }
|
||||
];
|
||||
async runPrecheck() {
|
||||
if (this.state.pc === 'running') return;
|
||||
this.setState({ checks: Array(6).fill('run'), pc: 'running', started: false, error: '' });
|
||||
try {
|
||||
const response = await fetch('/api/v1/preflight', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
if (!response.ok) throw new Error('预检服务返回 ' + response.status);
|
||||
const result = await response.json();
|
||||
this._names = result.checks.map(c => ({ name: c.name, note: c.note }));
|
||||
this.setState({
|
||||
checks: result.checks.map(c => c.passed ? 'ok' : 'bad'),
|
||||
pc: result.passed ? 'done' : 'failed'
|
||||
});
|
||||
} catch (error) {
|
||||
this.setState({ checks: Array(6).fill('bad'), pc: 'failed', error: error.message || 'API 不可用' });
|
||||
}
|
||||
}
|
||||
async start() {
|
||||
if (this.state.pc !== 'done' || this.state.started) return;
|
||||
this.setState({ started: true, error: '' });
|
||||
try {
|
||||
const response = await fetch('/api/v1/runs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ loops: 8, inject_failure: true, created_by: 'console' })
|
||||
});
|
||||
if (!response.ok) throw new Error('创建任务失败: ' + response.status);
|
||||
const run = await response.json();
|
||||
localStorage.setItem('flashops.activeRunId', run.id);
|
||||
this.setState({ startedRun: run.id });
|
||||
} catch (error) {
|
||||
this.setState({ started: false, error: error.message || '任务创建失败' });
|
||||
}
|
||||
}
|
||||
renderVals() {
|
||||
const mono = { fontFamily: 'ui-monospace,Menlo,monospace' };
|
||||
const checks = this._names.map((n, i) => {
|
||||
const st = this.state.checks[i];
|
||||
return {
|
||||
...n,
|
||||
icon: st === 'ok' ? '✓' : st === 'bad' ? '×' : st === 'run' ? '●' : '·',
|
||||
iconStyle: { width: '14px', textAlign: 'center', color: st === 'ok' ? 'var(--color-accent-700)' : st === 'bad' ? '#8a2e22' : st === 'run' ? 'var(--color-accent)' : 'var(--color-neutral-400)', animation: st === 'run' ? 'omPulse .8s infinite' : 'none' },
|
||||
txtStyle: { color: st === 'idle' ? 'var(--color-neutral-500)' : 'var(--color-text)' }
|
||||
};
|
||||
});
|
||||
const pc = this.state.pc;
|
||||
return {
|
||||
checks,
|
||||
pcBusy: pc === 'running',
|
||||
pcNote: pc === 'idle' ? '未运行' : pc === 'running' ? '检查中…' : pc === 'done' ? '6/6 通过 · API 已确认' : (this.state.error || '存在阻断项'),
|
||||
pcColor: pc === 'done' ? 'var(--color-accent-700)' : 'var(--color-neutral-500)',
|
||||
startDisabled: pc !== 'done' || this.state.started,
|
||||
started: this.state.started,
|
||||
startedRun: this.state.startedRun || '正在分配…',
|
||||
runPrecheck: () => this.runPrecheck(),
|
||||
start: () => this.start()
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,239 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css">
|
||||
<script src="_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f2f2f3;min-width:1280px}
|
||||
*{scrollbar-width:thin;scrollbar-color:var(--color-accent-300) transparent}
|
||||
a{color:#5980a6}a:hover{color:#416180}
|
||||
@keyframes omIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes omScan{to{background-position:28px 0}}
|
||||
@keyframes omDraw{to{stroke-dashoffset:0}}
|
||||
@keyframes omGrow{from{width:0}}
|
||||
</style>
|
||||
</helmet>
|
||||
<dc-import name="ConsoleNav" active="workflows" hint-size="100%,92px"></dc-import>
|
||||
<div data-screen-label="工作流模板" style="max-width:1400px;margin:0 auto;padding:22px 24px 64px;font-family:var(--font-body);animation:omIn .5s cubic-bezier(.2,.7,.2,1) both">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;margin-bottom:18px">
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:.18em;color:var(--color-accent-700);font-weight:500">08 · WORKFLOWS</div>
|
||||
<h2 style="margin:2px 0 0;font-size:30px">工作流模板</h2>
|
||||
<div class="text-muted" style="font-size:12.5px;margin-top:2px">SOP 结构化 · 版本化 · 审批与签名 — 运行时禁止拼接未审核的危险命令</div>
|
||||
</div>
|
||||
<span style="margin-left:auto"></span>
|
||||
<button class="btn btn-secondary" style="font-size:12.5px">从 SOP 文档草拟(AI)</button>
|
||||
<button class="btn btn-primary" style="font-size:13px">新建模板</button>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:330px 1fr;gap:18px;align-items:start">
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<sc-for list="{{ list }}" as="tp" hint-placeholder-count="4">
|
||||
<div onClick="{{ tp.pick }}" tabindex="0" style="{{ tp.itemStyle }}">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:12.5px;font-weight:700">{{ tp.name }}</span>
|
||||
<span style="margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500)">{{ tp.ver }}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--color-neutral-700);margin:4px 0 6px">{{ tp.desc }}</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<span style="{{ tp.stStyle }}">{{ tp.status }}</span>
|
||||
<sc-for list="{{ tp.risks }}" as="rk" hint-placeholder-count="2"><span style="font-family:ui-monospace,Menlo,monospace;font-size:9px;background:var(--color-accent-800);color:#f2f2f3;padding:1px 6px">{{ rk }}</span></sc-for>
|
||||
<span style="margin-left:auto;font-size:10px;color:var(--color-neutral-500)">{{ tp.updated }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
<div style="border:1px dashed var(--color-divider);padding:11px 12px;font-size:11.5px;color:var(--color-neutral-600);line-height:1.6">每接入一套真实 SOP,就沉淀为可复用模板与连接器 · 模板参数化,严格控制定制边界</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 18px;gap:10px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<h4 style="margin:0;letter-spacing:.01em">{{ selName }}</h4>
|
||||
<span style="{{ selStStyle }}">{{ selStatus }}</span>
|
||||
<span style="margin-left:auto"></span>
|
||||
<button class="btn btn-secondary" style="font-size:12px;padding:5px 12px">复制为新版本</button>
|
||||
<button class="btn btn-secondary" style="font-size:12px;padding:5px 12px">导出 YAML</button>
|
||||
<button class="btn btn-primary" disabled="{{ selPublished }}" style="font-size:12px;padding:5px 14px">提交审批</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--color-neutral-700)">
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">{{ selVer }} · workflow_version 1</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">签名 {{ selSig }}</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">来源 {{ selSource }}</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">检查点:每循环</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:2px 8px">恢复策略 esc-default-v2</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 340px;gap:16px;align-items:start">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:14px 16px;gap:4px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0 0 8px;color:var(--color-neutral-700)">步骤定义 · STEPS</h6>
|
||||
<sc-for list="{{ steps }}" as="st" hint-placeholder-count="10">
|
||||
<div style="{{ st.rowStyle }}">
|
||||
<span style="{{ st.numStyle }}">{{ st.num }}</span>
|
||||
<div style="flex:1">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:600">{{ st.name }}</span>
|
||||
<sc-if value="{{ st.risk }}" hint-placeholder-val="{{ false }}"><span style="font-family:ui-monospace,Menlo,monospace;font-size:9px;background:var(--color-accent-800);color:#f2f2f3;padding:1px 6px">{{ st.risk }}</span></sc-if>
|
||||
</div>
|
||||
<div style="font-size:11.5px;color:var(--color-neutral-600);margin-top:1px">{{ st.note }}</div>
|
||||
</div>
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500);white-space:nowrap">{{ st.meta }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
<div style="border:1px dashed #b03d2e;padding:10px 12px;margin-top:10px">
|
||||
<div style="font-size:10px;letter-spacing:.12em;color:#8a2e22;margin-bottom:6px">ON_FAILURE · 失败处理管道</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--color-neutral-800);flex-wrap:wrap">
|
||||
<span style="border:1px solid var(--color-divider);padding:3px 9px;background:var(--color-bg)">evidence.capture_all</span>
|
||||
<span style="color:var(--color-neutral-400)">→</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:3px 9px;background:var(--color-bg)">recovery.escalate</span>
|
||||
<span style="color:var(--color-neutral-400)">→</span>
|
||||
<span style="border:1px solid var(--color-divider);padding:3px 9px;background:var(--color-bg)">failure.classify</span>
|
||||
<span style="font-size:10px;color:var(--color-neutral-600);margin-left:4px">取证先于恢复 · 破坏性步骤不自动重试</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px 14px;gap:8px;border-color:var(--color-accent-600)">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">安全声明 · SAFETY</h6>
|
||||
<div style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px;line-height:1.9;background:var(--color-neutral-100);border:1px solid var(--color-divider);padding:9px 11px;color:var(--color-neutral-800)">safety:<br> allowed_dut_serials: {{ selSerials }}<br> destructive_write: {{ selDestr }}<br> require_approval: true<br> system_disk_guard: enforce<br>variables:<br> cycles: {{ selCycles }}<br> firmware: "${firmware}"</div>
|
||||
<div style="font-size:11px;color:var(--color-neutral-600)">Format / Sanitize / 刷写只能来自签名模板,参数受 Schema 限制</div>
|
||||
</div>
|
||||
<div class="card blueprint" style-hover="border-color:var(--color-accent-400);box-shadow:var(--shadow-sm)" style="transition:border-color .15s ease,box-shadow .15s ease;padding:12px 14px;gap:8px">
|
||||
<i class="corner tl"></i><i class="corner tr"></i><i class="corner bl"></i><i class="corner br"></i>
|
||||
<h6 style="margin:0;color:var(--color-neutral-700)">审批记录 · AUDIT</h6>
|
||||
<div style="display:flex;flex-direction:column">
|
||||
<sc-for list="{{ approvals }}" as="ap" hint-placeholder-count="4">
|
||||
<div style="display:flex;gap:10px;padding:5px 0;border-bottom:1px solid rgba(29,31,32,.06);font-size:11.5px">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--color-neutral-500);min-width:44px">{{ ap.t }}</span>
|
||||
<span style="{{ ap.dotStyle }}"></span>
|
||||
<span style="flex:1">{{ ap.what }}</span>
|
||||
<span style="color:var(--color-neutral-600)">{{ ap.who }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":880}}">
|
||||
class Component extends DCLogic {
|
||||
state = { sel: 0 };
|
||||
renderVals() {
|
||||
const mono = { fontFamily: 'ui-monospace,Menlo,monospace' };
|
||||
const stOf = st => {
|
||||
const b = { fontSize: '10px', padding: '2px 9px', letterSpacing: '.04em' };
|
||||
if (st === '已发布') return { ...b, background: 'var(--color-accent-100)', color: 'var(--color-accent-800)' };
|
||||
if (st === '审批中') return { ...b, border: '1px solid var(--color-accent-600)', color: 'var(--color-accent-700)' };
|
||||
return { ...b, border: '1px dashed var(--color-neutral-500)', color: 'var(--color-neutral-700)' };
|
||||
};
|
||||
const T = [
|
||||
{ name: 'fw_ab_powerstate_regression', ver: 'v3', status: '已发布', risks: ['破坏写', '刷写'], desc: '固件 A/B 休眠/唤醒 100 循环回归 — 首个落地 SOP', updated: '07-15', sig: '8e02…c4', source: 'SOP-FW-012(客户脱敏版)', serials: '["SAMPLE-001","SAMPLE-002"]', destr: 'true', cycles: '100',
|
||||
steps: [
|
||||
{ num: '01', name: 'lab.precheck', note: '安全预检 · 环境指纹 · 序列号/系统盘校验', meta: 'timeout 120s', risk: null, lvl: 0 },
|
||||
{ num: '02', name: 'vendor.flash_firmware', note: 'image: ${firmware} · 哈希校验 · slot 激活', meta: 'timeout 300s · retry 1', risk: '固件更新', lvl: 0 },
|
||||
{ num: '03', name: 'host.reboot', note: 'wait_for agent_online · 版本确认', meta: 'timeout 600s', risk: null, lvl: 0 },
|
||||
{ num: 'L', name: 'loop · repeat ${cycles}', note: '检查点:每循环写入,失败从安全点续跑', meta: 'cycles 100', risk: null, lvl: -1 },
|
||||
{ num: '·1', name: 'nvme.collect_baseline', note: 'SMART / Error Log / Telemetry 基线', meta: '30s', risk: null, lvl: 1 },
|
||||
{ num: '·2', name: 'io.run_profile', note: 'profile 4k_mixed · duration 300s · QD32', meta: '写入', risk: '破坏写', lvl: 1 },
|
||||
{ num: '·3', name: 'host.hibernate', note: 'S4 · 断言电源状态达成', meta: 'timeout 180s', risk: null, lvl: 1 },
|
||||
{ num: '·4', name: 'host.wait_resume', note: 'RTC 唤醒 · Agent 上线', meta: 'timeout 300s', risk: null, lvl: 1 },
|
||||
{ num: '·5', name: 'dut.verify_enumeration', note: '设备节点 · 链路宽度/速率断言', meta: 'retry 3', risk: null, lvl: 1 },
|
||||
{ num: '·6', name: 'data.verify_hash', note: '校验集 sha256 对比 · 失败范围定位', meta: '120s', risk: null, lvl: 1 }
|
||||
],
|
||||
approvals: [
|
||||
{ t: '07-15', what: 'v3 审核通过并发布 · 签名 8e02…c4', who: '李工 · 验证组长', kind: 'ok' },
|
||||
{ t: '07-12', what: 'v3 提交审批:唤醒断言加严(+链路速率)', who: 'quan.zhang', kind: 'n' },
|
||||
{ t: '07-08', what: 'v2 修订:循环 50→100 · 检查点改每循环', who: 'quan.zhang', kind: 'n' },
|
||||
{ t: '06-30', what: 'v1 由 SOP-FW-012 草拟(AI 初稿+人工修订)', who: 'quan.zhang', kind: 'n' }
|
||||
] },
|
||||
{ name: 'fw_retention_72h', ver: 'v1', status: '审批中', risks: ['破坏写'], desc: '高温 72h 数据保持,分段校验', updated: '07-24', sig: '待签名', source: 'SOP-FW-019', serials: '["SAMPLE-004"]', destr: 'true', cycles: '3 段 ×24h',
|
||||
steps: [
|
||||
{ num: '01', name: 'lab.precheck', note: '含温箱通道检查', meta: 'timeout 120s', risk: null, lvl: 0 },
|
||||
{ num: '02', name: 'io.write_retention_set', note: '写入 1.2TB 校验集 · 记录 LBA 映射', meta: '≈70min', risk: '破坏写', lvl: 0 },
|
||||
{ num: 'L', name: 'loop · repeat 3', note: '每 24h 一段,段间校验', meta: '72h', risk: null, lvl: -1 },
|
||||
{ num: '·1', name: 'chamber.hold_temp', note: '55°C 恒温 24h · 温度证据采集', meta: '24h', risk: null, lvl: 1 },
|
||||
{ num: '·2', name: 'data.verify_hash', note: '分段读校验 · 错误率统计', meta: '90min', risk: null, lvl: 1 }
|
||||
],
|
||||
approvals: [
|
||||
{ t: '07-24', what: 'v1 提交审批 · 等待验证组长', who: 'quan.zhang', kind: 'n' },
|
||||
{ t: '07-22', what: 'v1 由 SOP-FW-019 草拟', who: 'quan.zhang', kind: 'n' }
|
||||
] },
|
||||
{ name: 'fw_coldboot_matrix', ver: 'v2', status: '已发布', risks: ['刷写'], desc: '500 次冷启动 · 枚举与启动时序断言', updated: '07-10', sig: '5fd0…9a', source: 'SOP-FW-007', serials: '["SAMPLE-006"]', destr: 'false', cycles: '500',
|
||||
steps: [
|
||||
{ num: '01', name: 'lab.precheck', note: '安全预检 · OOB AC 通道确认', meta: 'timeout 120s', risk: null, lvl: 0 },
|
||||
{ num: 'L', name: 'loop · repeat 500', note: '冷启动矩阵', meta: 'cycles 500', risk: null, lvl: -1 },
|
||||
{ num: '·1', name: 'oob.power_cycle', note: '整机 AC 断电 · 安全间隔 30s', meta: 'OOB 动作', risk: null, lvl: 1 },
|
||||
{ num: '·2', name: 'host.wait_boot', note: 'POST → OS → Agent 上线计时', meta: 'timeout 600s', risk: null, lvl: 1 },
|
||||
{ num: '·3', name: 'dut.verify_enumeration', note: '枚举耗时断言 <5s', meta: 'retry 0', risk: null, lvl: 1 }
|
||||
],
|
||||
approvals: [
|
||||
{ t: '07-10', what: 'v2 审核通过并发布', who: '李工 · 验证组长', kind: 'ok' },
|
||||
{ t: '07-06', what: 'v2 修订:加启动时序断言', who: 'quan.zhang', kind: 'n' }
|
||||
] },
|
||||
{ name: 'incoming_qc_quick', ver: 'v5', status: '草稿', risks: [], desc: '来料抽检 30min 快检(只读+短写)', updated: '今日', sig: '未签名', source: '内部模板', serials: '["批量·入库检标签"]', destr: 'false', cycles: '1',
|
||||
steps: [
|
||||
{ num: '01', name: 'lab.precheck', note: '批量扫码 · 序列号登记比对', meta: 'timeout 120s', risk: null, lvl: 0 },
|
||||
{ num: '02', name: 'nvme.identify_baseline', note: 'Identify / SMART / 固件版本核对', meta: '60s', risk: null, lvl: 0 },
|
||||
{ num: '03', name: 'io.short_stress', note: '5min 混合负载 · 温升记录', meta: '5min', risk: '破坏写', lvl: 0 },
|
||||
{ num: '04', name: 'report.batch_summary', note: '批次基线对比 · 标签打印', meta: '30s', risk: null, lvl: 0 }
|
||||
],
|
||||
approvals: [
|
||||
{ t: '今日', what: 'v5 草稿修订中(批次基线阈值)', who: 'quan.zhang', kind: 'n' }
|
||||
] }
|
||||
];
|
||||
const sel = T[this.state.sel];
|
||||
const list = T.map((tp, i) => ({
|
||||
...tp,
|
||||
pick: () => this.setState({ sel: i }),
|
||||
stStyle: stOf(tp.status),
|
||||
itemStyle: {
|
||||
border: i === this.state.sel ? '1px solid var(--color-accent-600)' : '1px solid var(--color-divider)',
|
||||
background: i === this.state.sel ? 'var(--color-accent-100)' : 'transparent',
|
||||
padding: '11px 13px', cursor: 'pointer', transition: 'border-color .15s ease, background .15s ease'
|
||||
}
|
||||
}));
|
||||
const steps = sel.steps.map((st, i) => {
|
||||
const isLoop = st.lvl === -1;
|
||||
return {
|
||||
...st,
|
||||
rowStyle: {
|
||||
animation: 'omIn .4s cubic-bezier(.2,.7,.2,1) both', animationDelay: (i * 45) + 'ms',
|
||||
display: 'flex', gap: '12px', alignItems: 'flex-start', padding: '8px 6px',
|
||||
borderBottom: '1px solid rgba(29,31,32,.07)',
|
||||
marginLeft: st.lvl === 1 ? '26px' : 0,
|
||||
borderLeft: st.lvl === 1 ? '2px solid var(--color-accent-300)' : isLoop ? '2px solid var(--color-accent)' : '2px solid transparent',
|
||||
paddingLeft: '12px',
|
||||
background: isLoop ? 'var(--color-accent-100)' : 'transparent'
|
||||
},
|
||||
numStyle: { ...mono, minWidth: '24px', fontSize: '10.5px', color: isLoop ? 'var(--color-accent-800)' : 'var(--color-neutral-500)', paddingTop: '2px', fontWeight: isLoop ? 700 : 400 }
|
||||
};
|
||||
});
|
||||
const approvals = sel.approvals.map(ap => ({
|
||||
...ap,
|
||||
dotStyle: { width: '8px', height: '8px', borderRadius: '50%', flex: 'none', marginTop: '4px', background: ap.kind === 'ok' ? 'var(--color-accent)' : 'var(--color-bg)', border: ap.kind === 'ok' ? 'none' : '1.5px solid var(--color-neutral-400)' }
|
||||
}));
|
||||
return {
|
||||
list, steps, approvals,
|
||||
selName: sel.name, selVer: sel.ver, selStatus: sel.status, selStStyle: stOf(sel.status),
|
||||
selSig: sel.sig, selSource: sel.source, selSerials: sel.serials, selDestr: sel.destr, selCycles: sel.cycles,
|
||||
selPublished: sel.status === '已发布'
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"plugins": [
|
||||
"react",
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
"react/forbid-elements": [
|
||||
"warn",
|
||||
{
|
||||
"forbid": []
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
"patterns": []
|
||||
}
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
{
|
||||
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
|
||||
"message": "Raw hex color — use a design-system color token via var()."
|
||||
},
|
||||
{
|
||||
"selector": "Literal[value=/\\b\\d+px\\b/]",
|
||||
"message": "Raw px value — use a design-system spacing token via var()."
|
||||
},
|
||||
{
|
||||
"selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Barlow|Barlow Condensed))/i]",
|
||||
"message": "Font not provided by the design system. Available: Barlow, Barlow Condensed."
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/index.js"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-omelette": {
|
||||
"components": {},
|
||||
"tokens": [
|
||||
"--color-accent",
|
||||
"--color-accent-100",
|
||||
"--color-accent-2",
|
||||
"--color-accent-2-100",
|
||||
"--color-accent-2-200",
|
||||
"--color-accent-2-300",
|
||||
"--color-accent-2-400",
|
||||
"--color-accent-2-500",
|
||||
"--color-accent-2-600",
|
||||
"--color-accent-2-700",
|
||||
"--color-accent-2-800",
|
||||
"--color-accent-2-900",
|
||||
"--color-accent-200",
|
||||
"--color-accent-300",
|
||||
"--color-accent-400",
|
||||
"--color-accent-500",
|
||||
"--color-accent-600",
|
||||
"--color-accent-700",
|
||||
"--color-accent-800",
|
||||
"--color-accent-900",
|
||||
"--color-bg",
|
||||
"--color-divider",
|
||||
"--color-neutral-100",
|
||||
"--color-neutral-200",
|
||||
"--color-neutral-300",
|
||||
"--color-neutral-400",
|
||||
"--color-neutral-500",
|
||||
"--color-neutral-600",
|
||||
"--color-neutral-700",
|
||||
"--color-neutral-800",
|
||||
"--color-neutral-900",
|
||||
"--color-surface",
|
||||
"--color-text",
|
||||
"--font-body",
|
||||
"--font-heading",
|
||||
"--font-heading-weight",
|
||||
"--radius-lg",
|
||||
"--radius-md",
|
||||
"--radius-sm",
|
||||
"--shadow-lg",
|
||||
"--shadow-md",
|
||||
"--shadow-sm",
|
||||
"--space-1",
|
||||
"--space-2",
|
||||
"--space-3",
|
||||
"--space-4",
|
||||
"--space-6",
|
||||
"--space-8"
|
||||
],
|
||||
"tokenKinds": {
|
||||
"--color-bg": "color",
|
||||
"--color-surface": "color",
|
||||
"--color-text": "font",
|
||||
"--color-accent": "color",
|
||||
"--color-accent-2": "color",
|
||||
"--color-divider": "color",
|
||||
"--color-neutral-100": "color",
|
||||
"--color-neutral-200": "color",
|
||||
"--color-neutral-300": "color",
|
||||
"--color-neutral-400": "color",
|
||||
"--color-neutral-500": "color",
|
||||
"--color-neutral-600": "color",
|
||||
"--color-neutral-700": "color",
|
||||
"--color-neutral-800": "color",
|
||||
"--color-neutral-900": "color",
|
||||
"--color-accent-100": "color",
|
||||
"--color-accent-200": "color",
|
||||
"--color-accent-300": "color",
|
||||
"--color-accent-400": "color",
|
||||
"--color-accent-500": "color",
|
||||
"--color-accent-600": "color",
|
||||
"--color-accent-700": "color",
|
||||
"--color-accent-800": "color",
|
||||
"--color-accent-900": "color",
|
||||
"--color-accent-2-100": "color",
|
||||
"--color-accent-2-200": "color",
|
||||
"--color-accent-2-300": "color",
|
||||
"--color-accent-2-400": "color",
|
||||
"--color-accent-2-500": "color",
|
||||
"--color-accent-2-600": "color",
|
||||
"--color-accent-2-700": "color",
|
||||
"--color-accent-2-800": "color",
|
||||
"--color-accent-2-900": "color",
|
||||
"--font-heading": "font",
|
||||
"--font-heading-weight": "font",
|
||||
"--font-body": "font",
|
||||
"--space-1": "spacing",
|
||||
"--space-2": "spacing",
|
||||
"--space-3": "spacing",
|
||||
"--space-4": "spacing",
|
||||
"--space-6": "spacing",
|
||||
"--space-8": "spacing",
|
||||
"--radius-sm": "radius",
|
||||
"--radius-md": "radius",
|
||||
"--radius-lg": "radius",
|
||||
"--shadow-sm": "shadow",
|
||||
"--shadow-md": "shadow",
|
||||
"--shadow-lg": "shadow"
|
||||
},
|
||||
"fontFamilies": [
|
||||
"Barlow",
|
||||
"Barlow Condensed"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* @ds-bundle: {"format":4,"namespace":"Industry_indust","components":[],"sourceHashes":{},"inlinedExternals":[],"unexposedExports":[]} */
|
||||
|
||||
(() => {
|
||||
|
||||
const __ds_ns = (window.Industry_indust = window.Industry_indust || {});
|
||||
|
||||
const __ds_scope = {};
|
||||
|
||||
(__ds_ns.__errors = __ds_ns.__errors || []);
|
||||
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
|
||||
# Industry design system
|
||||
|
||||
Industry is a wireframe: steel-blue on a light technical ground, Barlow Condensed headings over Barlow, a modular grid, and cards, figures and buttons framed as blueprint objects — square-cornered, hairline-bordered, with "+" registration marks at the corners. Cards and figures stay transparent line drawings; the primary button is the one solid object on the board, an accent fill that keeps the square corners and the marks. Photography is duotoned into the steel accent and icons are thin-stroke.
|
||||
|
||||
## How to use this
|
||||
|
||||
- Link the one stylesheet from every page — `<link rel="stylesheet" href="styles.css">` (adjust the relative path) — and take every color, font, spacing, radius and shadow from its variables (`var(--color-*)`, `var(--font-*)`, `var(--space-*)`, `var(--radius-*)`, `var(--shadow-*)`). Never hard-code a hex, a font name or a px value the tokens already carry.
|
||||
- Build with the classes below rather than inventing parallel ones; the component pages are plain HTML, so view source and copy the markup.
|
||||
- `templates/` holds starting points a consuming project can copy whole.
|
||||
- The whole system was derived from `theme.json`. To change the look, edit the tokens at the top of `styles.css` — every page, the thumbnail and this guide read from them — and keep `theme.json` and the written guidance in step so they don't drift from what the CSS actually does.
|
||||
|
||||
## Direction
|
||||
|
||||
Modular grid layouts — content in equal-width cells, strong horizontal and vertical rhythm, visible structure. Cards, buttons and major sections are wireframe objects: square-cornered, thin-bordered, with `+` crosshair corner marks (the `.blueprint` class + four `<i class="corner tl/tr/bl/br">` children) — never soft filled rounded blocks. Images and figures get the same treatment: square, hairline-framed and marked, never rounded or clipped. Wrap hero and inline images in the `.duotone` class — they are desaturated and washed in the accent, like a screen print that re-colors with the theme.
|
||||
|
||||
## Color
|
||||
|
||||
A light ground (`--color-bg` #f2f2f3) with `--color-text` #1d1f20 and a single accent #5980a6 (this is a mono scheme: no second accent was chosen — the `--color-accent-2-*` variables carry a machine-derived stand-in kept only so both sets resolve; treat them as one role). Each role carries a 100–900 tonal ramp (`--color-neutral-100` … `--color-accent-2-900`) generated in OKLCH on a shared perceptual lightness scale, so the same step of any ramp has the same visual weight. Use the light steps (100–300) for tinted fills, hovers and subtle borders, 500 as the role's base, and the dark steps (700–900) for text on tinted fills and for pressed states; prefer ramp steps over ad-hoc `color-mix()`. For elevation use `--shadow-sm/md/lg` (already tuned to the ground) rather than ad-hoc box-shadows.
|
||||
|
||||
## Type
|
||||
|
||||
Barlow Condensed for headings over Barlow for body text, loaded as `--font-heading` / `--font-body`. Density 0.85× and radius 4px are already baked into the `--space-*` / `--radius-*` scales — use the variables, not raw numbers.
|
||||
|
||||
## Icons
|
||||
|
||||
Use Lucide icons (https://lucide.dev), at stroke-width 1.5 for a lighter, more technical look throughout.
|
||||
|
||||
## Interaction states
|
||||
|
||||
Interactive states are themed, never browser defaults: give every interactive element a `:hover` tint and a pressed state from the accent ramp (one step past the base — `--color-accent-600` on a light ground, `--color-accent-400` on a dark one, or a `color-mix()` tint for outlined/ghost variants), and style keyboard focus with `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }` — never leave the default blue focus ring.
|
||||
|
||||
## Components
|
||||
|
||||
| Class | What it is | Shown in |
|
||||
| --- | --- | --- |
|
||||
| `.btn` with `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-icon`, `.btn-block` | Actions — the primary is a solid accent fill | components/buttons.html |
|
||||
| `.tag` with `.tag-accent`, `.tag-accent-2`, `.tag-neutral`, `.tag-outline` | Small labels tinted from the ramps (mono palette: accent-2 reads the same as accent) | components/buttons.html |
|
||||
| `.field` + `label`, `.input`, `.radio` + `.dot`, `.seg` + `.seg-opt` | Form fields and choices on native elements — no script | components/forms.html |
|
||||
| `.card` with `.card-kicker`, `.card-title`, `.card-body`, `.card-meta`; `.elev-sm/md/lg` | Transparent, hairline-bordered cards with corner registration marks | components/cards.html |
|
||||
| `.nav` + `.nav-brand` | The header bar | components/navigation.html |
|
||||
| `.table` | Data tables with themed header and row rules | components/table.html |
|
||||
| `.dialog-backdrop` + `.dialog` (+ `.dialog-title/-body/-actions`) | A modal at the top elevation | components/dialog.html |
|
||||
| `.hr` | A horizontal rule — present, but this system prefers whitespace; avoid it | — |
|
||||
| `.blueprint` + four `<i class="corner tl/tr/bl/br">` children | The wireframe frame every card, figure and primary button wears | components/cards.html |
|
||||
| `.duotone` | The image wrapper — every content photograph goes through it | foundations/image.html |
|
||||
|
||||
States are built in: hovers and pressed states come from the accent ramp, keyboard focus is the 2px accent `:focus-visible` ring, `::selection` is an accent tint, and disabled controls drop to 45% opacity. Don't restyle them per page. The accent-to-ground pair is tuned to at least 3:1 — enough for icons, large text and interface chrome, not for body copy — so for paragraph-size text in the accent use a deep ramp step (`--color-accent-700` on this ground) rather than the accent itself.
|
||||
|
||||
## Do
|
||||
|
||||
- Frame cards, figures and primary buttons as blueprint objects: the `.blueprint` class plus four `<i class="corner …">` marks.
|
||||
- Keep the grid visible — equal cells, strong horizontal and vertical rhythm.
|
||||
- Condense headings (Barlow Condensed) and keep body copy in Barlow.
|
||||
- Duotone photographs with the `.duotone` wrapper so they take the accent.
|
||||
|
||||
## Don't
|
||||
|
||||
- Do not round cards, figures or buttons, and do not give cards or figures a surface fill — they are line drawings (the solid accent primary button is the one deliberate exception).
|
||||
- Do not drop the registration marks from a framed element.
|
||||
- Do not use thick icon strokes; the set is Lucide at 1.5.
|
||||
- Do not add decorative color beyond the steel accent. The accent's own deep step (`--color-accent-900`) may carry a full field where the deck's section dividers use it — steel as ground, type reversed to paper. (The landing's numbers sit on a drawn spec-sheet plate on the paper ground instead — its own grammar, not a field.)
|
||||
|
||||
## Files
|
||||
|
||||
- `styles.css` — the only stylesheet: the token sheet (`:root` variables, ramps, base type) plus the component layer. Link it from every page.
|
||||
- `readme.md` — this guide.
|
||||
- `theme.json` — the parameters these files were derived from (a machine-readable record of the theme).
|
||||
- `thumbnail.html` — the project cover (brand mark + swatches).
|
||||
- `foundations/type.html` — the type scale and the heading/body pairing at real sizes.
|
||||
- `foundations/color.html` — color roles and the 100-900 tonal ramps, with usage notes.
|
||||
- `foundations/layout.html` — the spacing scale, the grid and how edges are drawn.
|
||||
- `foundations/icons.html` — the icon set at interface sizes, inline and in buttons.
|
||||
- `foundations/image.html` — how photographs and figures are treated.
|
||||
- `components/buttons.html` — buttons, icon buttons and tags in every variant and state.
|
||||
- `components/forms.html` — text fields, radios and the segmented control on native elements.
|
||||
- `components/cards.html` — content cards and the elevation steps.
|
||||
- `components/navigation.html` — the header bar pattern.
|
||||
- `components/table.html` — a data table with the themed header and row rules.
|
||||
- `components/dialog.html` — a modal over its backdrop at the top elevation.
|
||||
- `theme.html` — the theme's parameters rendered as a reference sheet.
|
||||
- `templates/landing/` — a starter page consuming the system the intended way (`index.html`, its `ds-base.js` loader, and the vendored `image-slot.js` its photograph mounts).
|
||||
- `assets/photo.jpg` — the reference photograph the imagery page treats.
|
||||
@@ -0,0 +1,285 @@
|
||||
/* Industry — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;700&family=Barlow+Condensed:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--color-bg: #f2f2f3;
|
||||
--color-surface: #e9e9ea;
|
||||
--color-text: #1d1f20;
|
||||
--color-accent: #5980a6;
|
||||
--color-accent-2: #728fab;
|
||||
--color-divider: color-mix(in srgb, #1d1f20 16%, transparent);
|
||||
|
||||
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
|
||||
same step of any role matches the others in visual value. */
|
||||
--color-neutral-100: #f5f5f8;
|
||||
--color-neutral-200: #e7e7ea;
|
||||
--color-neutral-300: #d4d4d7;
|
||||
--color-neutral-400: #b7b7ba;
|
||||
--color-neutral-500: #98989b;
|
||||
--color-neutral-600: #7a7a7d;
|
||||
--color-neutral-700: #5d5d60;
|
||||
--color-neutral-800: #424244;
|
||||
--color-neutral-900: #2b2b2d;
|
||||
|
||||
--color-accent-100: #eef6ff;
|
||||
--color-accent-200: #d6ebff;
|
||||
--color-accent-300: #b5d9fd;
|
||||
--color-accent-400: #94bce3;
|
||||
--color-accent-500: #749dc4;
|
||||
--color-accent-600: #597ea3;
|
||||
--color-accent-700: #416180;
|
||||
--color-accent-800: #2c455d;
|
||||
--color-accent-900: #1d2d3d;
|
||||
|
||||
--color-accent-2-100: #eef6ff;
|
||||
--color-accent-2-200: #d6ebff;
|
||||
--color-accent-2-300: #bdd8f2;
|
||||
--color-accent-2-400: #9ebbd8;
|
||||
--color-accent-2-500: #7e9cb8;
|
||||
--color-accent-2-600: #627d98;
|
||||
--color-accent-2-700: #486077;
|
||||
--color-accent-2-800: #314457;
|
||||
--color-accent-2-900: #1f2d3a;
|
||||
|
||||
--font-heading: "Barlow Condensed", system-ui, sans-serif;
|
||||
--font-heading-weight: 600;
|
||||
--font-body: "Barlow", system-ui, sans-serif;
|
||||
|
||||
--space-1: 3.4px;
|
||||
--space-2: 6.8px;
|
||||
--space-3: 10.2px;
|
||||
--space-4: 13.6px;
|
||||
--space-6: 20.4px;
|
||||
--space-8: 27.2px;
|
||||
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 7px;
|
||||
|
||||
/* Elevation — derived from the ground: soft ink-tinted shadows on a
|
||||
light theme, a hairline edge + ambient darkness on a dark one. */
|
||||
--shadow-sm: 0 1px 2px color-mix(in srgb, #2b2b2d 14%, transparent);
|
||||
--shadow-md: 0 3px 10px color-mix(in srgb, #2b2b2d 16%, transparent);
|
||||
--shadow-lg: 0 12px 32px color-mix(in srgb, #2b2b2d 22%, transparent);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
|
||||
|
||||
.blueprint {
|
||||
position: relative;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0;
|
||||
}
|
||||
/* The overlay image treatments (halftone, duotone) clip their overlay
|
||||
(overflow:hidden); a blueprint wrapper draws its registration marks
|
||||
outside the box, so when both classes share a wrapper the frame must
|
||||
win. */
|
||||
.blueprint.halftone, .blueprint.plate, .blueprint.duotone { overflow: visible; }
|
||||
.blueprint > .corner {
|
||||
position: absolute; width: 11px; height: 11px;
|
||||
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||
}
|
||||
.blueprint > .corner::before, .blueprint > .corner::after {
|
||||
content: ""; position: absolute; background: currentColor;
|
||||
}
|
||||
.blueprint > .corner::before { left: 5px; top: 0; width: 1px; height: 100%; }
|
||||
.blueprint > .corner::after { top: 5px; left: 0; width: 100%; height: 1px; }
|
||||
.blueprint > .corner.tl { top: -6px; left: -6px; }
|
||||
.blueprint > .corner.tr { top: -6px; right: -6px; }
|
||||
.blueprint > .corner.bl { bottom: -6px; left: -6px; }
|
||||
.blueprint > .corner.br { bottom: -6px; right: -6px; }
|
||||
|
||||
.duotone{position:relative;overflow:hidden}
|
||||
.duotone::after{content:"";position:absolute;inset:0;pointer-events:none;
|
||||
background:var(--color-accent);mix-blend-mode:color}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Components — built with the tokens above. Plain CSS
|
||||
on plain HTML: no JavaScript, no build step. Each class is documented in
|
||||
readme.md and demonstrated in foundations/ and components/.
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
|
||||
}
|
||||
h1 { font-size: 42px; }
|
||||
h2 { font-size: 32px; }
|
||||
h3 { font-size: 25px; }
|
||||
h4 { font-size: 20px; }
|
||||
h5 { font-size: 16px; }
|
||||
h6 { font-size: 13px; }
|
||||
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
p { margin: 0 0 var(--space-3); }
|
||||
a { color: var(--color-accent); text-underline-offset: 3px; }
|
||||
img { display: block; max-width: 100%; }
|
||||
figure { margin: 0; }
|
||||
figcaption {
|
||||
font-size: 11px; margin-top: var(--space-1);
|
||||
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||
}
|
||||
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||
:focus { outline: none; }
|
||||
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
|
||||
|
||||
/* — rules — */
|
||||
.hr {
|
||||
height: 1px; border: 0; margin: var(--space-4) 0;
|
||||
background: var(--color-divider);
|
||||
}
|
||||
|
||||
/* — buttons — */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
cursor: pointer; text-decoration: none;
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
|
||||
the pair sits side by side in sign-up rows */
|
||||
background: transparent; border: 1px solid transparent;
|
||||
padding: var(--space-2) calc(var(--space-3) * 1.2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.btn svg { display: block; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
|
||||
.btn-primary:hover { background: var(--color-accent-600); }
|
||||
.btn-primary:active { background: var(--color-accent-700); }
|
||||
.btn-secondary { border-color: var(--color-divider); }
|
||||
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
|
||||
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
|
||||
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
|
||||
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
|
||||
.btn-icon { width: 36px; height: 36px; padding: 0; }
|
||||
.btn-block { width: 100%; margin-top: var(--space-2); }
|
||||
|
||||
/* — forms — */
|
||||
.field > label {
|
||||
display: block; font-size: 12px; margin-bottom: 5px;
|
||||
color: color-mix(in srgb, var(--color-text) 70%, transparent);
|
||||
}
|
||||
.input {
|
||||
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
|
||||
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
|
||||
}
|
||||
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
|
||||
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
|
||||
textarea.input { min-height: 90px; resize: vertical; }
|
||||
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
|
||||
.radio input, .seg-opt input {
|
||||
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
|
||||
}
|
||||
.radio .dot {
|
||||
width: 16px; height: 16px; flex: none; border-radius: 50%;
|
||||
border: 1.5px solid var(--color-divider);
|
||||
}
|
||||
.radio:hover .dot { border-color: var(--color-accent); }
|
||||
.radio input:checked + .dot {
|
||||
border-color: var(--color-accent); background: var(--color-accent);
|
||||
box-shadow: inset 0 0 0 4px var(--color-bg);
|
||||
}
|
||||
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
.seg {
|
||||
display: inline-flex; overflow: hidden;
|
||||
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
|
||||
}
|
||||
.seg-opt {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 12px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
|
||||
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
|
||||
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
|
||||
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
|
||||
|
||||
/* — cards — */
|
||||
.card {
|
||||
display: flex; flex-direction: column; gap: var(--space-2);
|
||||
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
|
||||
}
|
||||
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
|
||||
.card-title {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 17px; line-height: 1.2;
|
||||
}
|
||||
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
|
||||
.card-meta {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 11px;
|
||||
color: color-mix(in srgb, var(--color-text) 50%, transparent);
|
||||
}
|
||||
.elev-sm { box-shadow: var(--shadow-sm); }
|
||||
.elev-md { box-shadow: var(--shadow-md); }
|
||||
.elev-lg { box-shadow: var(--shadow-lg); }
|
||||
|
||||
/* — tags — */
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; font-size: 11px;
|
||||
letter-spacing: 0.02em; padding: 3px 10px;
|
||||
border-radius: calc(var(--radius-md) * 0.75);
|
||||
}
|
||||
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
|
||||
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
|
||||
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
|
||||
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
|
||||
|
||||
/* — navigation — */
|
||||
.nav {
|
||||
display: flex; align-items: center; gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: none;
|
||||
}
|
||||
.nav-brand {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 18px; margin-right: auto;
|
||||
}
|
||||
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
|
||||
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
|
||||
|
||||
/* — tables — */
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.table th {
|
||||
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--color-text) 60%, transparent);
|
||||
padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
.table td {
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||
}
|
||||
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
|
||||
|
||||
/* — dialog — */
|
||||
.dialog-backdrop {
|
||||
position: fixed; inset: 0; display: grid; place-items: center;
|
||||
padding: var(--space-4);
|
||||
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
|
||||
}
|
||||
.dialog {
|
||||
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
|
||||
padding: var(--space-4); border-radius: var(--radius-lg);
|
||||
background: var(--color-surface); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.dialog-title {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 20px;
|
||||
}
|
||||
.dialog-body { font-size: 14px; opacity: 0.85; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
|
||||
|
||||
/* — blueprint frame: components are wireframe objects (see .blueprint
|
||||
and .corner above) — square, transparent, hairline-bordered — */
|
||||
.card, .btn, .input, .tag, .seg, .dialog { border-radius: 0; }
|
||||
.card, .dialog { background: transparent; border: 1px solid var(--color-divider); }
|
||||
.btn { border: 1px solid var(--color-divider); }
|
||||
.btn-primary { border-color: var(--color-accent); }
|
||||
.btn-ghost { border-color: transparent; }
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user