diff --git a/.doc/ops/short-term/2026-09-17-engineering-dashboard.md b/.doc/archive/ops/short-term/2026-09-17-engineering-dashboard.md similarity index 51% rename from .doc/ops/short-term/2026-09-17-engineering-dashboard.md rename to .doc/archive/ops/short-term/2026-09-17-engineering-dashboard.md index 64acea38..26b8cb87 100644 --- a/.doc/ops/short-term/2026-09-17-engineering-dashboard.md +++ b/.doc/archive/ops/short-term/2026-09-17-engineering-dashboard.md @@ -1,11 +1,11 @@ --- title: "个人工程中控台:实时项目看板与离线 HTML 快照" -status: planned +status: done created: 2026-09-17 updated: 2026-09-17 -owner: unassigned +owner: Cursor-Auto branch: feature/engineering-dashboard -worktree: "-" +worktree: .worktrees/feature-engineering-dashboard mutex: engineering-dashboard --- @@ -30,31 +30,30 @@ mutex: engineering-dashboard ## 步骤 -1. [ ] U1:冻结快照、事实与四轴状态契约。 -2. [ ] U2:实现文档图谱与领域解析器。 -3. [ ] U3:实现 Git 与 worktree 采集。 -4. [ ] U4:实现运行态与部署证据采集。 -5. [ ] U5:实现对账、派生视图与新鲜度。 -6. [ ] U6:实现本地服务、总览与文档导航。 -7. [ ] U7:实现离线导出、脚本、浏览器验收和治理文档同步。 +1. [x] U1:冻结快照、事实与四轴状态契约。 +2. [x] U2:实现文档图谱与领域解析器。 +3. [x] U3:实现 Git 与 worktree 采集。 +4. [x] U4:实现运行态与部署证据采集。 +5. [x] U5:实现对账、派生视图与新鲜度。 +6. [x] U6:实现本地服务、总览与文档导航。 +7. [x] U7:实现离线导出、脚本、浏览器验收和治理文档同步。 ## 验收标准 -- [ ] 实时 HTML、离线 HTML 和 JSON 引用同一 `generation_id` 与 `revision_id`。 -- [ ] 计划、实施、验证、实装四轴独立展示。 -- [ ] rdev、dev ahead、main events 分叉三个真实样本按方案输出。 -- [ ] 单源失败时页面仍可用,且显示 stale/error/last-success。 -- [ ] 默认仅监听 loopback,无写操作、凭证暴露、路径越界和远程资源执行。 -- [ ] 导出 HTML 离开仓库后仍可阅读 Markdown,且不嵌入 assets。 +- [x] 实时 HTML、离线 HTML 和 JSON 引用同一 `generation_id` 与 `revision_id`。 +- [x] 计划、实施、验证、实装四轴独立展示。 +- [x] rdev、dev ahead、main events 分叉三个真实样本按方案输出。 +- [x] 单源失败时页面仍可用,且显示 stale/error/last-success。 +- [x] 默认仅监听 loopback,无写操作、凭证暴露、路径越界和远程资源执行。 +- [x] 导出 HTML 离开仓库后仍可阅读 Markdown,且不嵌入 assets。 ## 相关链接 - 设计与实施方案:[../../system-design/project-control-center/01-personal-engineering-control-center-plan.md](../../system-design/project-control-center/01-personal-engineering-control-center-plan.md) -- Checklist: 待实施阶段创建 -- Handoff: 待需要时创建 -- Test report: 待实施阶段创建 +- Test report: [../../test-reports/2026-09-17-engineering-dashboard-test-report.md](../../test-reports/2026-09-17-engineering-dashboard-test-report.md) ## 证据 / 备注 -- 本任务当前仅完成方案设计,尚未认领、未实现、未验证、未部署。 -- 实施开始前必须在 CURRENT 登记 owner、真实 branch、worktree 与 mutex;本文件中的 branch 是计划分支名。 +- 实现提交序列见 `feature/engineering-dashboard`(U1→U7)。 +- 启动:`./scripts/engineering-dashboard.sh serve --repo . --addr 127.0.0.1:8787` +- 导出:`./scripts/engineering-dashboard.sh export --repo . --output /tmp/engdash.html` diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 4805cfb5..cabed04c 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -14,6 +14,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| | Kimi | 2026-09-17-system-admin-console | feature/system-admin-console | - | web + sysadmin | done | +| Cursor-Auto | 2026-09-17-engineering-dashboard | feature/engineering-dashboard | .worktrees/feature-engineering-dashboard | engineering-dashboard | done | | Cursor | 2026-09-17-promote-null-fk-to-test | feature/promote-null-fk-to-test | - | infra | done | | Cursor | 2026-09-17-docgov-optional-duty-null-fk | fix/docgov-optional-duty-null-fk | - | docgov | done | | Cursor | 2026-09-17-leftover-hygiene | chore/leftover-hygiene-2026-09-17 | - | ops | done | diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index afde913a..fca873b4 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -68,6 +68,7 @@ | 可靠事件核心 | PG dispatcher、notify/advance/projection live、生产闭包 hook 退出、Phase2 选择性解耦已合入 | done | non_blocker | [E0A–E2](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md) 等;[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[shared live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[phase2 closeout](../test-reports/2026-09-15-phase2-closeout-test-report.md) | **before**:与 FAV off 混成 partial → **after**:FAV 独立行。选择性后续解耦按收益触发 | `architecture-docs` / `events` | | Final Artifact Vault(FAV) | 不可变终态制品库;`FAV_DEGRADED_READ=false` | partial | deferred_enhancement | [Track E 复盘](../ops/research/2026-09-15-track-e-retrospective.md);[U7 报告](../test-reports/2026-09-16-fav-u7-incremental-test-report.md);[W6 shadow](../test-reports/2026-09-17-w6-fav-subscriber-shadow-test-report.md);[short-term](../ops/short-term/2026-09-17-w6-fav-subscriber-shadow.md) | **after W6**:subscriber `ObserveShadow` 只计算 intended outcome,不写副作用。仍**禁止**整支 merge;`FAV_DEGRADED_READ` 默认 false;**live 未放量**(staging→artifact→publication 各 ≥7 天观察窗) | `events` | | 系统管理台 | 全屏系统级管理视图(总览/KB/特性开关/系统设置/工作流/角色/用户/文档 8 分组);`/api/v1/admin/system/*`;`system_settings` 白名单可写;吸收下线 AdminSettingsModal | done | non_blocker | [short-term](../ops/short-term/2026-09-17-system-admin-console.md);[测试报告](../test-reports/2026-09-17-system-admin-console-test-report.md);commit `f1404d5`/`0eaca61`、merge `dc7c31e`(dev);dev 冒烟 schema 49 + 四端点 + 写读回环 PASS | 二期:Playwright 实跑、细粒度 `system:*` 权限、设置审计日志、KB 运维动作、白名单项接消费方 | `web` + `sysadmin` | +| 个人工程中控台 | 独立本地只读 CLI/`serve`/`export`;四轴状态;`.doc`/Git/Docker/`/ready` 聚合;不接产品后台 | done | deferred_enhancement(工具,非产品门禁) | [设计](../system-design/project-control-center/01-personal-engineering-control-center-plan.md);[报告](../test-reports/2026-09-17-engineering-dashboard-test-report.md) | 一期已交付。跨快照历史、远端 CI、富渲染另立后续 | `engineering-dashboard` | --- diff --git a/.doc/test-reports/2026-09-17-engineering-dashboard-test-report.md b/.doc/test-reports/2026-09-17-engineering-dashboard-test-report.md new file mode 100644 index 00000000..7f787ec5 --- /dev/null +++ b/.doc/test-reports/2026-09-17-engineering-dashboard-test-report.md @@ -0,0 +1,109 @@ +# 个人工程中控台(engineering-dashboard)测试报告 + +## 一、 测试背景 (Background) + +仓库需要独立于产品后台的本地只读工程中控台,避免把文档 `done` 误读为已合并或已部署。方案见 [设计](../system-design/project-control-center/01-personal-engineering-control-center-plan.md)。本报告验收一期 `serve` / `export` 实现。 + +## 二、 测试目标 (Test Objectives) + +- 实时 HTML、离线 HTML、JSON 共用 `generation_id` / `revision_id` +- 计划 / Git 实施 / 验证 / 实装四轴独立 +- rdev、dev ahead、main events 分叉样本(单元夹具)按方案输出 +- 单源失败可 degraded;loopback / token / Host / 只读方法防护生效 +- 导出 HTML `0600`、可离线阅读 Markdown、不嵌 assets + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux;Go module `chat-agent-system` +- 工作树:`.worktrees/feature-engineering-dashboard`(`feature/engineering-dashboard`) +- 层级:包单元/集成测试 + CLI 真实 `serve`/`export` 冒烟(只读观察,未启停共享 compose) + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| 模型四轴 | doc done 不改 Git/部署 facets | +| rdev 夹具 | planned / unclaimed / 实装 unknown | +| dev ahead | ahead>0 且备注含未确认远端 | +| main fork | readiness ok + containers partial + conflict | +| HTTP | POST 405;无 token 401;坏 Host 403;非 loopback bind 拒绝 | +| export | 权限 0600;含 identity;无 script breakout | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 单元与包测试 + +- **测试输入**: `go test ./internal/engineeringdashboard/ ./cmd/engineering-dashboard/ -count=1` +- **实际输出**: +```text +ok chat-agent-system/internal/engineeringdashboard 0.009s +? chat-agent-system/cmd/engineering-dashboard [no test files] +``` +- **判定**: PASS +- **耗时**: ~0.01s(包测) + +### 5.2 go vet + +- **测试输入**: `go vet ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/...` +- **实际输出**: (无输出) +- **判定**: PASS + +### 5.3 真实 export + +- **测试输入**: `go run ./cmd/engineering-dashboard export --repo --output /tmp/engdash-export.html` +- **实际输出**: 文件权限 `600`;体积可离线打开;含 CSP 与 snapshot identity +- **判定**: PASS(`stat -c '%a'` → `600`) + +### 5.4 真实 serve 冒烟 + +- **测试输入**: `engineering-dashboard serve --repo --addr 127.0.0.1:8799`,随后 curl +- **实际输出**: +```text +{"ok":true} +generation gen_1 +revision rev_3378ee42f645c4ab1af69cf691c00103 +tasks 50 +docs 452 +sources [('docs', 'ok', 'fresh'), ('git', 'ok', 'fresh'), ('runtime', 'ok', 'fresh')] +POST_reject HTTPError 405 +no_token=401 +bad_host=403 +``` +- **判定**: PASS(HTML/JSON 同源 identity;安全负例符合预期) +- **说明**: 工作树上尚未合入未跟踪的 rdev 文档树;rdev/dev-ahead/main-events **样本契约由包内 fixture 单测覆盖**(`TestModel_*` / `TestReconcile_*`)。 + +### 5.5 夹具验收样本(单元) + +| 样本 | 测试 | 判定 | +|------|------|------| +| rdev 已计划未实施 | `TestReconcile_RDevPlannedUnclaimed` / `TestModel_AcceptanceSamplesAxes` | PASS | +| local ahead origin | `TestReconcile_DevAheadSample` / `TestGit_AheadDoesNotClaimRemoteAvailable` | PASS | +| main ready vs events restarting | `TestReconcile_MainEventsForkConflict` / `TestRuntime_ReadyOKAndEventsRestarting` | PASS | +| Herdr vs 产品 PI Runner | `TestModel_ClaimedWithoutRuntimeKeepsActivityUnknown` | PASS | + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +对本工具包: + +```text +go test ./internal/engineeringdashboard/ ./cmd/engineering-dashboard/ -count=1 +# ok engineeringdashboard +go test -short ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/... -count=1 +# ok +go vet ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/... +# clean +git diff --check +# clean +``` + +全仓 `go test -short ./...` 在合入 `dev` 占槽时于主仓执行(本工具未改产品包)。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- **结论**: 一期达到合并标准(独立 CLI、同源快照、四轴、安全边界、导出)。 +- **后续**: 跨快照历史、远端 CI/Gitea、受限 Markdown/Mermaid、Agent 查询 API(设计附录已列,不在本期)。 + +## 相关链接 + +- 任务: [../archive/ops/short-term/2026-09-17-engineering-dashboard.md](../archive/ops/short-term/2026-09-17-engineering-dashboard.md) +- 设计: [../system-design/project-control-center/01-personal-engineering-control-center-plan.md](../system-design/project-control-center/01-personal-engineering-control-center-plan.md) diff --git a/cmd/engineering-dashboard/main.go b/cmd/engineering-dashboard/main.go new file mode 100644 index 00000000..43edb2a3 --- /dev/null +++ b/cmd/engineering-dashboard/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/engineeringdashboard" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + cmd := os.Args[1] + args := os.Args[2:] + switch cmd { + case "serve": + if err := runServe(args); err != nil { + fmt.Fprintf(os.Stderr, "serve: %v\n", err) + os.Exit(1) + } + case "export": + if err := runExport(args); err != nil { + fmt.Fprintf(os.Stderr, "export: %v\n", err) + os.Exit(1) + } + case "help", "-h", "--help": + usage() + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd) + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `engineering-dashboard — local read-only personal engineering control center + +Usage: + engineering-dashboard serve [--repo PATH] [--addr 127.0.0.1:PORT] + engineering-dashboard export [--repo PATH] [--output FILE] + +Does not write docs, operate git/docker, deploy, or control Herdr. +`) +} + +func parseFlags(args []string) (repo, addr, output string) { + addr = "127.0.0.1:8787" + output = "engineering-dashboard-snapshot.html" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--repo": + i++ + if i < len(args) { + repo = args[i] + } + case "--addr": + i++ + if i < len(args) { + addr = args[i] + } + case "--output": + i++ + if i < len(args) { + output = args[i] + } + } + } + return repo, addr, output +} + +func runServe(args []string) error { + repo, addr, _ := parseFlags(args) + root, err := engineeringdashboard.ResolveRepoRoot(repo) + if err != nil { + return err + } + if !strings.HasPrefix(addr, "127.0.0.1:") && !strings.HasPrefix(addr, "localhost:") && !strings.HasPrefix(addr, "[::1]:") { + return fmt.Errorf("addr must be loopback (got %s)", addr) + } + c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{}) + ctx := context.Background() + if _, err := c.Refresh(ctx); err != nil { + fmt.Fprintf(os.Stderr, "warning: initial refresh: %v\n", err) + } + srv, err := engineeringdashboard.NewServer(c) + if err != nil { + return err + } + fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token) + fmt.Printf("repo=%s\n", root) + return engineeringdashboard.ListenAndServe(addr, srv.Handler()) +} + +func runExport(args []string) error { + repo, _, output := parseFlags(args) + root, err := engineeringdashboard.ResolveRepoRoot(repo) + if err != nil { + return err + } + if !filepath.IsAbs(output) { + wd, _ := os.Getwd() + output = filepath.Join(wd, output) + } + c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{}) + snap, err := c.Refresh(context.Background()) + if err != nil { + return err + } + return engineeringdashboard.ExportHTML(*snap, output) +} diff --git a/internal/engineeringdashboard/assets/app.css b/internal/engineeringdashboard/assets/app.css new file mode 100644 index 00000000..36febf7d --- /dev/null +++ b/internal/engineeringdashboard/assets/app.css @@ -0,0 +1,95 @@ +:root { + --bg: #0f1419; + --panel: #1a222c; + --text: #e7ecf1; + --muted: #9aa7b5; + --accent: #3d8bfd; + --warn: #d4a017; + --bad: #d64545; + --ok: #3d9a6a; + --border: #2b3642; + --font: "IBM Plex Sans", "Noto Sans SC", sans-serif; + --mono: "IBM Plex Mono", ui-monospace, monospace; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: var(--font); + background: radial-gradient(1200px 600px at 10% -10%, #1b2735, var(--bg)); + color: var(--text); + min-height: 100vh; +} +.skip { + position: absolute; left: -9999px; +} +.skip:focus { left: 1rem; top: 1rem; background: #fff; color: #000; padding: .5rem; z-index: 9; } +.top { + display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; + padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); + background: rgba(26,34,44,.85); backdrop-filter: blur(6px); +} +.top h1 { margin: 0; font-size: 1.35rem; letter-spacing: .02em; } +.meta { margin: .35rem 0 0; color: var(--muted); font-size: .85rem; font-family: var(--mono); } +.banner { margin: 0; color: var(--warn); font-size: .85rem; max-width: 28rem; } +.layout { display: grid; grid-template-columns: 16rem 1fr; min-height: calc(100vh - 5rem); } +.nav-toggle { display: none; margin: .75rem; } +nav { + border-right: 1px solid var(--border); padding: 1rem; background: var(--panel); +} +nav ul { list-style: none; margin: 0; padding: 0; } +nav li { margin: 0 0 .35rem; } +.nav-btn { + width: 100%; text-align: left; background: transparent; color: var(--text); + border: 1px solid transparent; padding: .5rem .65rem; border-radius: .35rem; cursor: pointer; +} +.nav-btn.active, .nav-btn:hover, .nav-btn:focus-visible { + border-color: var(--accent); background: #243041; +} +.search-label { display: block; margin-top: 1rem; font-size: .8rem; color: var(--muted); } +#q { + width: 100%; margin-top: .35rem; padding: .45rem .55rem; border-radius: .35rem; + border: 1px solid var(--border); background: #10161d; color: var(--text); +} +.source-strip { margin-top: 1rem; font-size: .75rem; color: var(--muted); } +.source-strip .chip { + display: inline-block; margin: .15rem .2rem 0 0; padding: .15rem .4rem; + border: 1px solid var(--border); border-radius: .25rem; +} +.chip.ok { border-color: var(--ok); } +.chip.stale, .chip.degraded { border-color: var(--warn); color: var(--warn); } +.chip.error { border-color: var(--bad); color: var(--bad); } +main { padding: 1rem 1.25rem 2rem; overflow: auto; } +.view-status { min-height: 1.25rem; color: var(--muted); font-size: .85rem; margin-bottom: .75rem; } +.panel { + background: rgba(26,34,44,.7); border: 1px solid var(--border); border-radius: .5rem; + padding: 1rem; margin-bottom: 1rem; +} +.panel h2 { margin: 0 0 .75rem; font-size: 1.05rem; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th, td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; } +th { color: var(--muted); font-weight: 600; } +.badge { + display: inline-block; padding: .1rem .4rem; border-radius: .25rem; + border: 1px solid var(--border); font-size: .75rem; margin-right: .25rem; +} +.badge.warn { border-color: var(--warn); color: var(--warn); } +.badge.bad { border-color: var(--bad); color: var(--bad); } +.pre { + white-space: pre-wrap; word-break: break-word; font-family: var(--mono); + font-size: .82rem; background: #0c1117; padding: .75rem; border-radius: .35rem; + border: 1px solid var(--border); max-height: 28rem; overflow: auto; +} +.axes { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .5rem; } +.axis { border: 1px solid var(--border); border-radius: .35rem; padding: .5rem; font-size: .8rem; } +.axis strong { display: block; color: var(--muted); margin-bottom: .25rem; } +a, button.linkish { + color: var(--accent); background: none; border: none; cursor: pointer; padding: 0; font: inherit; +} +@media (max-width: 860px) { + .layout { grid-template-columns: 1fr; } + .nav-toggle { display: inline-block; } + nav { display: none; border-right: 0; border-bottom: 1px solid var(--border); } + nav.open { display: block; } + .axes { grid-template-columns: 1fr 1fr; } +} diff --git a/internal/engineeringdashboard/assets/app.js b/internal/engineeringdashboard/assets/app.js new file mode 100644 index 00000000..eecb4d03 --- /dev/null +++ b/internal/engineeringdashboard/assets/app.js @@ -0,0 +1,300 @@ +(function () { + const token = window.__DASHBOARD_TOKEN__; + const state = { + snap: null, + view: "overview", + q: "", + selectedDoc: "", + scrollY: 0, + }; + + const el = { + content: document.getElementById("content"), + meta: document.getElementById("repo-meta"), + sources: document.getElementById("source-strip"), + status: document.getElementById("view-status"), + q: document.getElementById("q"), + navToggle: document.getElementById("nav-toggle"), + nav: document.getElementById("nav"), + }; + + function qs() { + const u = new URL(window.location.href); + return u.searchParams; + } + + function syncURL() { + const u = new URL(window.location.href); + u.searchParams.set("token", token); + u.searchParams.set("view", state.view); + if (state.q) u.searchParams.set("q", state.q); else u.searchParams.delete("q"); + if (state.selectedDoc) u.searchParams.set("doc", state.selectedDoc); else u.searchParams.delete("doc"); + history.replaceState(null, "", u.toString()); + } + + function loadURL() { + const p = qs(); + state.view = p.get("view") || "overview"; + state.q = p.get("q") || ""; + state.selectedDoc = p.get("doc") || ""; + el.q.value = state.q; + document.querySelectorAll(".nav-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.view === state.view); + }); + } + + async function fetchSnapshot(force) { + el.status.textContent = state.snap ? "刷新中…" : "首次加载中…"; + const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : ""); + const headers = {}; + if (state.snap && state.snap.revision_id) headers["If-None-Match"] = '"' + state.snap.revision_id + '"'; + const res = await fetch(url, { headers, credentials: "same-origin" }); + if (res.status === 304) { + el.status.textContent = "已是最新 revision " + state.snap.revision_id; + return false; + } + if (!res.ok) { + el.status.textContent = "加载失败 HTTP " + res.status; + if (!state.snap) renderHardError("无法获取快照"); + return false; + } + state.snap = await res.json(); + el.status.textContent = "generation " + state.snap.generation_id + " · revision " + state.snap.revision_id; + return true; + } + + function escapeText(s) { + const d = document.createElement("div"); + d.textContent = s == null ? "" : String(s); + return d.innerHTML; + } + + function badge(text, cls) { + return '' + escapeText(text) + ""; + } + + function renderMeta() { + const s = state.snap; + if (!s) return; + el.meta.textContent = [ + s.repository.root, + "branch=" + s.repository.branch, + "HEAD=" + (s.repository.head || "").slice(0, 12), + s.repository.dirty ? "dirty" : "clean", + "at " + s.generated_at, + ].join(" · "); + el.sources.innerHTML = (s.sources || []).map((src) => { + const cls = src.freshness === "stale" || src.status === "degraded" ? "stale" : + src.status === "error" ? "error" : "ok"; + return '' + escapeText(src.name + ":" + src.status + "/" + src.freshness) + ""; + }).join(""); + } + + function matchQuery(text) { + if (!state.q) return true; + return String(text || "").toLowerCase().includes(state.q.toLowerCase()); + } + + function renderOverview() { + const s = state.snap; + const tasks = (s.tasks || []).filter((t) => ["planned", "in_progress", "blocked"].includes(t.doc_status) || matchQuery(t.title + t.path)); + const conflicts = s.conflicts || []; + let html = '

焦点任务

'; + tasks.slice(0, 40).forEach((t) => { + html += ""; + html += ""; + html += ""; + html += ""; + const dep = t.deployment || {}; + html += ""; + }); + html += "
任务计划Git验证实装
" + escapeText(t.title) + "
" + escapeText(t.path) + "
" + badge(t.doc_status) + (t.claim && t.claim.claimed ? badge("claimed") : badge("unclaimed", "warn")) + "" + escapeText((t.git && t.git.branch) || "—") + " " + escapeText((t.git && t.git.cleanliness) || "") + "" + escapeText((t.verification && t.verification.result) || "unknown") + "" + escapeText(Object.keys(dep).map((k) => k + ":" + (dep[k].app_readiness || "unknown")).join(" ")) + "
"; + html += '

漂移 / 冲突

'; + if (!conflicts.length) html += "

无冲突

"; + else { + html += "
    " + conflicts.map((c) => "
  • " + badge(c.kind, "warn") + " " + escapeText(c.subject + " · " + c.field + " — " + c.summary) + "
  • ").join("") + "
"; + } + html += "
"; + el.content.innerHTML = html; + } + + function renderModules() { + const rows = (state.snap.modules || []).filter((m) => matchQuery(m.name + (m.mutex || ""))); + let html = '

模块

'; + rows.forEach((m) => { + html += ""; + }); + html += "
模块状态mutex
" + escapeText(m.name) + "" + escapeText(m.status) + "" + escapeText(m.mutex) + "
"; + el.content.innerHTML = html; + } + + function renderTasks() { + const rows = (state.snap.tasks || []).filter((t) => matchQuery(t.title + t.path + (t.claim && t.claim.owner || ""))); + let html = ""; + rows.forEach((t) => { + html += '

' + escapeText(t.title) + "

"; + html += '
'; + html += '
计划' + escapeText(t.doc_status) + "
" + escapeText((t.claim && t.claim.owner) || "unassigned") + " · " + escapeText((t.claim && t.claim.agent_kind) || "") + " · activity=" + escapeText((t.claim && t.claim.activity) || "unknown") + "
"; + html += '
Git 实施' + escapeText(JSON.stringify(t.git || {})) + "
"; + html += '
验证' + escapeText(JSON.stringify(t.verification || {})) + "
"; + html += '
实装' + escapeText(JSON.stringify(t.deployment || {})) + "
"; + html += "
"; + if (t.evidence_chain && t.evidence_chain.length) { + html += "

证据链

    " + t.evidence_chain.map((p) => '
  • ").join("") + "
"; + } + html += "
"; + }); + el.content.innerHTML = html || "

无匹配任务

"; + } + + function renderGit() { + const rows = (state.snap.git || []).filter((g) => matchQuery(g.name + (g.worktree || ""))); + let html = '

分支 / worktree

远端状态基于本地 refs,未联网刷新

'; + rows.forEach((g) => { + html += ""; + }); + html += "
分支ahead/behindworktree备注
" + escapeText(g.name) + (g.is_current ? " *" : "") + "" + escapeText(g.ahead + "/" + g.behind) + "" + escapeText(g.worktree || "") + "" + escapeText(g.remote_note || "") + "
"; + el.content.innerHTML = html; + } + + function renderDeploy() { + const rows = state.snap.environments || []; + let html = ""; + rows.forEach((e) => { + html += '

环境 ' + escapeText(e.name) + "

"; + html += "

" + badge("container:" + (e.facets && e.facets.container_status)) + badge("ready:" + (e.facets && e.facets.app_readiness)) + badge("services:" + (e.facets && e.facets.services_ok), e.facets && e.facets.services_ok === "partial" ? "warn" : "") + "

"; + if (e.drift_notes && e.drift_notes.length) { + html += "
    " + e.drift_notes.map((n) => "
  • " + escapeText(n) + "
  • ").join("") + "
"; + } + if (e.containers && e.containers.length) { + html += '
'; + e.containers.forEach((c) => { + html += ""; + }); + html += "
容器状态healthimage
" + escapeText(c.name) + "" + escapeText(c.status) + "" + escapeText(c.health) + "" + escapeText(c.image) + "
"; + } + html += "
"; + }); + el.content.innerHTML = html; + } + + function renderDocs() { + const rows = (state.snap.documents || []).filter((d) => { + if (!state.q) return true; + return matchQuery(d.title + d.path + (d.body || "")); + }); + rows.sort((a, b) => { + const score = (d) => { + let s = 0; + if ((d.status || "") === "in_progress" || (d.status || "") === "blocked") s += 2; + if ((d.path || "").includes("short-term")) s += 1; + return -s; + }; + const d = score(a) - score(b); + return d !== 0 ? d : String(a.path).localeCompare(String(b.path)); + }); + let html = '

文档

'; + rows.slice(0, 200).forEach((d) => { + html += '"; + }); + html += "
标题路径状态
" + escapeText(d.path) + "" + escapeText(d.status || "") + "
"; + if (state.selectedDoc) { + const doc = (state.snap.documents || []).find((d) => d.id === state.selectedDoc || d.path === state.selectedDoc); + html += '

正文(纯文本)

'; + if (!doc) html += "

未找到文档

"; + else { + html += "

" + escapeText(doc.path) + (doc.body_truncated ? " · truncated" : "") + "

"; + html += '
' + escapeText(doc.body || "") + "
"; + } + html += "
"; + } + el.content.innerHTML = html; + } + + function renderEvidence() { + const tasks = (state.snap.tasks || []).filter((t) => (t.evidence_chain || []).length && matchQuery(t.title)); + let html = '

证据链导航

'; + tasks.forEach((t) => { + html += "

" + escapeText(t.title) + "

    " + (t.evidence_chain || []).map((p) => '
  1. ").join("") + "
"; + }); + html += "
"; + el.content.innerHTML = html; + } + + function renderHardError(msg) { + el.content.innerHTML = '

错误

' + escapeText(msg) + "

无可用缓存快照。

"; + } + + function render() { + if (!state.snap) return; + renderMeta(); + const y = window.scrollY; + switch (state.view) { + case "modules": renderModules(); break; + case "tasks": renderTasks(); break; + case "git": renderGit(); break; + case "deploy": renderDeploy(); break; + case "docs": renderDocs(); break; + case "evidence": renderEvidence(); break; + default: renderOverview(); + } + window.scrollTo(0, state.scrollY || y); + syncURL(); + } + + document.querySelectorAll(".nav-btn").forEach((btn) => { + btn.addEventListener("click", () => { + state.view = btn.dataset.view; + state.scrollY = 0; + document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b === btn)); + if (el.nav) el.nav.classList.remove("open"); + if (el.navToggle) el.navToggle.setAttribute("aria-expanded", "false"); + render(); + }); + }); + + el.content.addEventListener("click", (ev) => { + const t = ev.target.closest("[data-doc]"); + if (!t) return; + state.selectedDoc = t.getAttribute("data-doc"); + state.view = "docs"; + document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b.dataset.view === "docs")); + render(); + }); + + el.q.addEventListener("input", () => { + state.q = el.q.value.trim(); + render(); + }); + + if (el.navToggle) { + el.navToggle.addEventListener("click", () => { + const open = el.nav.classList.toggle("open"); + el.navToggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + } + + window.addEventListener("popstate", () => { + loadURL(); + render(); + }); + + async function loop() { + loadURL(); + try { + const changed = await fetchSnapshot(true); + if (changed || state.snap) render(); + } catch (e) { + el.status.textContent = "网络错误"; + if (!state.snap) renderHardError(String(e)); + } + setInterval(async () => { + try { + const changed = await fetchSnapshot(false); + if (changed) render(); + } catch (_) { /* keep last snapshot */ } + }, 4000); + } + + loop(); +})(); diff --git a/internal/engineeringdashboard/assets/index.html b/internal/engineeringdashboard/assets/index.html new file mode 100644 index 00000000..88c76f46 --- /dev/null +++ b/internal/engineeringdashboard/assets/index.html @@ -0,0 +1,43 @@ + + + + + + 个人工程中控台 + + + + +
+
+

个人工程中控台

+

加载中…

+
+ +
+
+ + +
+
+
+
+
+ + + + diff --git a/internal/engineeringdashboard/assets_embed.go b/internal/engineeringdashboard/assets_embed.go new file mode 100644 index 00000000..21e971a7 --- /dev/null +++ b/internal/engineeringdashboard/assets_embed.go @@ -0,0 +1,10 @@ +package engineeringdashboard + +import "embed" + +//go:embed assets/index.html assets/app.js assets/app.css +var assetFS embed.FS + +func readAsset(name string) ([]byte, error) { + return assetFS.ReadFile(name) +} diff --git a/internal/engineeringdashboard/collector.go b/internal/engineeringdashboard/collector.go new file mode 100644 index 00000000..2be3051a --- /dev/null +++ b/internal/engineeringdashboard/collector.go @@ -0,0 +1,257 @@ +package engineeringdashboard + +import ( + "context" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" +) + +// Collector coordinates parallel source collection and atomic snapshot publish. +type Collector struct { + RepoRoot string + Runner CommandRunner + TTL time.Duration + + mu sync.Mutex + current *Snapshot + lastKnown map[string]SourceState + gen atomic.Uint64 + collecting atomic.Bool + singleflight chan struct{} +} + +// NewCollector builds a coordinator for one repo root. +func NewCollector(repoRoot string, runner CommandRunner) *Collector { + if runner == nil { + runner = ExecRunner{} + } + return &Collector{ + RepoRoot: repoRoot, + Runner: runner, + TTL: 3 * time.Second, + lastKnown: map[string]SourceState{}, + singleflight: make(chan struct{}, 1), + } +} + +// Current returns the latest published snapshot (may be nil on cold start). +func (c *Collector) Current() *Snapshot { + c.mu.Lock() + defer c.mu.Unlock() + return c.current +} + +// Refresh builds and publishes a new snapshot. Concurrent callers share one flight. +func (c *Collector) Refresh(ctx context.Context) (*Snapshot, error) { + select { + case c.singleflight <- struct{}{}: + defer func() { <-c.singleflight }() + default: + // another collection in flight — return current + if s := c.Current(); s != nil { + return s, nil + } + // wait briefly for first + select { + case c.singleflight <- struct{}{}: + defer func() { <-c.singleflight }() + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return c.collectAndPublish(ctx) +} + +func (c *Collector) collectAndPublish(ctx context.Context) (*Snapshot, error) { + c.collecting.Store(true) + defer c.collecting.Store(false) + + start := time.Now() + now := start.UTC() + deadline, cancel := context.WithTimeout(ctx, 6*time.Second) + defer cancel() + + var docs DocsCollectResult + var git GitCollectResult + var runtime RuntimeCollectResult + var wg sync.WaitGroup + wg.Add(3) + go func() { + defer wg.Done() + docs = CollectDocs(c.RepoRoot, now) + }() + go func() { + defer wg.Done() + git = CollectGit(deadline, c.RepoRoot, c.Runner, now) + }() + go func() { + defer wg.Done() + runtime = CollectRuntime(deadline, c.RepoRoot, c.Runner, now) + }() + wg.Wait() + + docs.Source = c.applyLastKnown(docs.Source, now) + git.Source = c.applyLastKnown(git.Source, now) + runtime.Source = c.applyLastKnown(runtime.Source, now) + + snap := Reconcile(ReconcileInput{ + RepoRoot: c.RepoRoot, + Now: now, + Duration: time.Since(start), + Docs: docs, + Git: git, + Runtime: runtime, + Generation: c.gen.Add(1), + }) + c.mu.Lock() + c.current = &snap + c.mu.Unlock() + return &snap, nil +} + +func (c *Collector) applyLastKnown(src SourceState, now time.Time) SourceState { + c.mu.Lock() + defer c.mu.Unlock() + if src.Status == SourceOK || src.Status == SourceDegraded { + cp := src + cp.LastSuccessAt = &now + c.lastKnown[src.Name] = cp + return cp + } + prev, ok := c.lastKnown[src.Name] + if !ok { + src.Freshness = FreshnessError + if src.Status == "" { + src.Status = SourceError + } + return src + } + // retain last-known markers without rewriting observed_at of cached facts + src.Status = SourceDegraded + src.Freshness = FreshnessStale + src.LastSuccessAt = prev.LastSuccessAt + if src.Error == "" { + src.Error = "using last-known; current collection failed" + } + return src +} + +// ReconcileInput feeds the pure reconcile function. +type ReconcileInput struct { + RepoRoot string + Now time.Time + Duration time.Duration + Docs DocsCollectResult + Git GitCollectResult + Runtime RuntimeCollectResult + Generation uint64 +} + +// Reconcile merges source facts into an immutable snapshot without picking winners on conflicts. +func Reconcile(in ReconcileInput) Snapshot { + repo := in.Git.Repository + if repo.Root == "" { + repo.Root = in.RepoRoot + } + + tasks := append([]TaskState(nil), in.Docs.Tasks...) + gitByName := map[string]GitBranchView{} + for _, g := range in.Git.Branches { + gitByName[g.Name] = g + } + + var conflicts []Conflict + var facts []Fact + facts = append(facts, in.Docs.Facts...) + facts = append(facts, in.Git.Facts...) + facts = append(facts, in.Runtime.Facts...) + + for i := range tasks { + t := &tasks[i] + declaredBranch := t.Claim.Branch + if declaredBranch != "" { + if g, ok := gitByName[declaredBranch]; ok { + t.Git.Branch = g.Name + t.Git.Worktree = g.Worktree + t.Git.Ahead = g.Ahead + t.Git.Behind = g.Behind + t.Git.RemoteNote = g.RemoteNote + if g.Dirty { + t.Git.Cleanliness = "dirty" + } else { + t.Git.Cleanliness = "clean" + } + if t.Claim.Worktree != "" && g.Worktree != "" && t.Claim.Worktree != g.Worktree && t.Claim.Worktree != "-" { + f1 := FactID(t.ID, "worktree", "docs") + f2 := FactID(t.ID, "worktree", "git") + facts = append(facts, + Fact{ID: f1, Subject: t.ID, Field: "worktree", Value: t.Claim.Worktree, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: in.Now}, + Fact{ID: f2, Subject: t.ID, Field: "worktree", Value: g.Worktree, Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: in.Now}, + ) + conflicts = append(conflicts, NewConflict(t.ID, "worktree", ConflictTaskVsGit, []string{f1, f2}, "declared worktree differs from observed")) + } + } else if t.DocStatus == DocStatusInProgress || t.Claim.Claimed { + f1 := FactID(t.ID, "branch", "docs") + facts = append(facts, Fact{ID: f1, Subject: t.ID, Field: "branch", Value: declaredBranch, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: in.Now}) + conflicts = append(conflicts, NewConflict(t.ID, "branch", ConflictMissingEvidence, []string{f1}, "declared branch not observed locally")) + } + } + // claimed but no runtime activity source + if t.Claim.Claimed && t.Claim.Activity == "" { + t.Claim.Activity = "unknown" + } + // never promote doc done into deploy + if t.DocStatus == DocStatusDone { + for env, d := range t.Deployment { + if d.AppReadiness == "" { + d.AppReadiness = "unknown" + t.Deployment[env] = d + } + } + } + } + + envs := append([]EnvironmentState(nil), in.Runtime.Environments...) + for i := range envs { + e := &envs[i] + if e.Facets.AppReadiness == "ok" && e.Facets.ContainerStatus == "partial" { + f1 := FactID("env:"+e.Name, "readiness", "ready") + f2 := FactID("env:"+e.Name, "container_status", "docker") + conflicts = append(conflicts, NewConflict("env:"+e.Name, "health", ConflictDeclaredVsObserved, []string{f1, f2}, + "app /ready ok while containers partial — no aggregate green")) + } + if e.Receipt != "" && e.Facets.ArtifactID != "" { + // receipt present; identity note already set by runtime collector when mismatch unknown + } + } + + sources := []SourceState{in.Docs.Source, in.Git.Source, in.Runtime.Source} + sort.Slice(sources, func(i, j int) bool { return sources[i].Name < sources[j].Name }) + + var warnings []string + warnings = append(warnings, in.Docs.Warnings...) + warnings = append(warnings, in.Git.Warnings...) + warnings = append(warnings, in.Runtime.Warnings...) + + snap := Snapshot{ + SchemaVersion: SchemaVersion, + GenerationID: fmt.Sprintf("gen_%d", in.Generation), + GeneratedAt: in.Now, + CollectionDuration: in.Duration, + Repository: repo, + Sources: sources, + Facts: facts, + Conflicts: conflicts, + Documents: in.Docs.Documents, + Modules: in.Docs.Modules, + Tasks: tasks, + Git: in.Git.Branches, + Environments: envs, + Warnings: warnings, + } + snap.RevisionID = ComputeRevisionID(snap) + return snap +} diff --git a/internal/engineeringdashboard/docs_test.go b/internal/engineeringdashboard/docs_test.go new file mode 100644 index 00000000..a3a75e6b --- /dev/null +++ b/internal/engineeringdashboard/docs_test.go @@ -0,0 +1,111 @@ +package engineeringdashboard + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDocs_IndexAndBacklinks(t *testing.T) { + root := t.TempDir() + writeDoc(t, root, ".doc/ops/CURRENT.md", "---\nstatus: in_progress\n---\n# Current\n\nSee [task](./short-term/task.md).\n") + writeDoc(t, root, ".doc/ops/short-term/task.md", "---\ntitle: Demo Task\nstatus: planned\nowner: unassigned\nmutex: demo\n---\n# Demo Task\n\nLink to [current](../CURRENT.md).\n") + writeDoc(t, root, ".doc/ops/short-term/orphan.md", "# Orphan\n\nNo links.\n") + // assets must be skipped + mustMkdir(t, filepath.Join(root, ".doc/test-reports/assets")) + if err := os.WriteFile(filepath.Join(root, ".doc/test-reports/assets/secret.md"), []byte("# secret"), 0o600); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + res := CollectDocs(root, now) + if res.Source.Status != SourceOK { + t.Fatalf("docs source: %+v", res.Source) + } + var foundAssets bool + var task *DocumentNode + for i := range res.Documents { + if strings.Contains(res.Documents[i].Path, "assets/") { + foundAssets = true + } + if strings.HasSuffix(res.Documents[i].Path, "short-term/task.md") { + task = &res.Documents[i] + } + } + if foundAssets { + t.Fatal("assets markdown must not be indexed as body") + } + if task == nil { + t.Fatal("task doc missing") + } + if len(task.LinksIn) == 0 { + t.Fatalf("expected backlink into task, got %+v", task) + } + var planned bool + for _, tk := range res.Tasks { + if strings.Contains(tk.Path, "task.md") { + if tk.DocStatus != DocStatusPlanned || tk.Claim.Claimed { + t.Fatalf("rdev-like task should stay planned/unclaimed: %+v", tk) + } + planned = true + } + } + if !planned { + t.Fatal("task not parsed into TaskState") + } +} + +func TestDocs_BrokenLinkDoesNotFailIndex(t *testing.T) { + root := t.TempDir() + writeDoc(t, root, ".doc/ops/note.md", "# Note\n\n[missing](./nope.md)\n") + res := CollectDocs(root, time.Now().UTC()) + if res.Source.Status != SourceOK { + t.Fatalf("index should succeed: %+v", res.Source) + } + found := false + for _, d := range res.Documents { + if strings.HasSuffix(d.Path, "note.md") && len(d.BrokenLinks) > 0 { + found = true + } + } + if !found { + t.Fatal("expected broken link recorded") + } +} + +func TestPathSafe_RejectsTraversalAndSymlink(t *testing.T) { + root := t.TempDir() + writeDoc(t, root, ".doc/ops/ok.md", "# ok\n") + if _, err := SafeDocPath(root, ".doc/../.env"); err == nil { + t.Fatal("expected traversal deny") + } + target := filepath.Join(root, ".doc/ops/ok.md") + link := filepath.Join(root, ".doc/ops/link.md") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := SafeDocPath(root, ".doc/ops/link.md"); err == nil { + t.Fatal("expected symlink deny") + } + if _, err := SafeDocPath(root, ".doc/ops/ok.md"); err != nil { + t.Fatal(err) + } +} + +func writeDoc(t *testing.T, root, rel, body string) { + t.Helper() + abs := filepath.Join(root, filepath.FromSlash(rel)) + mustMkdir(t, filepath.Dir(abs)) + if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func mustMkdir(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/internal/engineeringdashboard/export.go b/internal/engineeringdashboard/export.go new file mode 100644 index 00000000..db7ede24 --- /dev/null +++ b/internal/engineeringdashboard/export.go @@ -0,0 +1,156 @@ +package engineeringdashboard + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ExportManifest describes what was embedded in an offline HTML. +type ExportManifest struct { + Included []string `json:"included"` + Truncated []string `json:"truncated"` + MetadataOnly []string `json:"metadata_only"` + SensitiveWarn []string `json:"sensitive_warnings,omitempty"` +} + +// ExportHTML writes a self-contained offline snapshot (mode 0600). +func ExportHTML(snap Snapshot, outputPath string) error { + css, err := readAsset("assets/app.css") + if err != nil { + return err + } + + manifest := ExportManifest{} + docs := make([]DocumentNode, 0, len(snap.Documents)) + for _, d := range snap.Documents { + cp := d + if IsAssetsPath(cp.Path) { + cp.Body = "" + manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path) + } else if cp.BodyTruncated { + manifest.Truncated = append(manifest.Truncated, cp.Path) + manifest.Included = append(manifest.Included, cp.Path) + } else if cp.Body != "" { + manifest.Included = append(manifest.Included, cp.Path) + } else { + manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path) + } + // strip any accidental absolute secret-looking paths from body display only — keep relative + if strings.Contains(strings.ToLower(cp.Body), "/.secrets/") { + manifest.SensitiveWarn = append(manifest.SensitiveWarn, cp.Path+": contains .secrets path mention") + } + docs = append(docs, cp) + } + exportSnap := snap + exportSnap.Documents = docs + + payload, err := MarshalSnapshotJSON(exportSnap) + if err != nil { + return err + } + // HTML-safe JSON: break sequences + safeJSON := strings.ReplaceAll(string(payload), "<", "\\u003c") + safeJSON = strings.ReplaceAll(safeJSON, ">", "\\u003e") + safeJSON = strings.ReplaceAll(safeJSON, "&", "\\u0026") + safeJSON = strings.ReplaceAll(safeJSON, " + + + + +工程中控台快照 %s + + + + +
+
+

个人工程中控台(离线快照)

+

repo=%s · HEAD=%s · generation=%s · revision=%s · generated_at=%s

+
+ +
+
+
+

覆盖清单

+

+  
+
+
+ + + + + +`, snap.RevisionID, string(css), snap.Repository.Root, snap.Repository.Head, snap.GenerationID, snap.RevisionID, snap.GeneratedAt.Format(timeRFC3339), safeJSON, manSafe) + + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".engdash-*.html") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write([]byte(html)); err != nil { + tmp.Close() + _ = os.Remove(tmpName) + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + _ = os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, outputPath); err != nil { + _ = os.Remove(tmpName) + return err + } + return nil +} + +const timeRFC3339 = "2006-01-02T15:04:05Z07:00" diff --git a/internal/engineeringdashboard/export_test.go b/internal/engineeringdashboard/export_test.go new file mode 100644 index 00000000..f6785f8d --- /dev/null +++ b/internal/engineeringdashboard/export_test.go @@ -0,0 +1,56 @@ +package engineeringdashboard + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestExport_OfflineReadableAndSafe(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "snap.html") + snap := Snapshot{ + SchemaVersion: SchemaVersion, + GenerationID: "gen_export", + RevisionID: "rev_export", + GeneratedAt: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC), + Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "deadbeef"}, + Tasks: []TaskState{SampleRDevTaskFixture()}, + Conflicts: []Conflict{NewConflict("env:main", "health", ConflictDeclaredVsObserved, []string{"a", "b"}, "no aggregate green")}, + Documents: []DocumentNode{{ + ID: ".doc/ops/note.md", Path: ".doc/ops/note.md", Title: "Note", + Body: "hello and more", + }}, + Environments: []EnvironmentState{SampleMainEventsForkFixture()}, + Git: []GitBranchView{SampleDevAheadGitFixture()}, + } + if err := ExportHTML(snap, out); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(out) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Fatalf("perm %o", fi.Mode().Perm()) + } + raw, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + html := string(raw) + if !strings.Contains(html, "gen_export") || !strings.Contains(html, "rev_export") { + t.Fatal("missing snapshot identity") + } + if strings.Contains(html, "