diff --git a/.agents/skills/unidesk-monitor/SKILL.md b/.agents/skills/unidesk-monitor/SKILL.md new file mode 100644 index 00000000..a38decd7 --- /dev/null +++ b/.agents/skills/unidesk-monitor/SKILL.md @@ -0,0 +1,58 @@ +--- +name: unidesk-monitor +description: UniDesk monitoring and Web sentinel operations. Use when working on monitor.pikapython.com, HWLAB Web哨兵, web-probe sentinel status/report/dashboard, Prometheus/OTel monitoring, multi-sentinel runtime visibility, or monitoring-related issue triage and rollout evidence. +--- + +# UniDesk Monitor + +本技能是 UniDesk 监控与 Web 哨兵操作面的入口。它不替代 `$unidesk-webdev`、`$unidesk-cicd`、`$unidesk-ymalops`、`$unidesk-gh` 或 `$unidesk-otel`;遇到对应工作时同时加载那些技能。 + +## Boundaries + +- Web 哨兵只 wrap 现有 `web-probe observe start/status/command/collect/analyze`,不得新增第二套 Playwright runner、采样器、报告器或 analyzer。 +- 哨兵观察对象是 HWLAB Web 用户入口和业务 E2E 链路;不是让一个哨兵观察另一个哨兵。 +- YAML 是 source of truth。node/lane、sentinel id、Deployment、Service、PVC、route prefix、cadence、Secret sourceRef、dashboard public URL 和 report views 都必须从 YAML/configRef 进入受控 CLI。 +- 正式读写 GitHub issue/PR 走 `$unidesk-gh`;部署、Argo、git-mirror、PipelineRun、runtime 状态走 `$unidesk-cicd` 和受控 CLI;YAML 正规化走 `$unidesk-ymalops`。 +- 诊断可用 `curl` 或一次性 `web-probe script` 采证,但重复 dashboard 验证必须沉淀为受控 `web-probe sentinel dashboard verify|screenshot` 或等价入口。 + +## Quick Commands + +```bash +bun scripts/cli.ts web-probe sentinel status --node D601 --lane v03 +bun scripts/cli.ts web-probe sentinel status --node D601 --lane v03 --sentinel +bun scripts/cli.ts web-probe sentinel control-plane status --node D601 --lane v03 --sentinel +bun scripts/cli.ts web-probe sentinel validate --node D601 --lane v03 --sentinel +bun scripts/cli.ts web-probe sentinel dashboard verify --node D601 --lane v03 --sentinel +bun scripts/cli.ts web-probe sentinel dashboard screenshot --node D601 --lane v03 --sentinel +bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel --latest --view summary +``` + +For long Workbench/user-path evidence, use the normal Web probe surface: + +```bash +bun scripts/cli.ts web-probe observe start --node D601 --lane v03 --target-path /workbench +bun scripts/cli.ts web-probe observe command --type +bun scripts/cli.ts web-probe observe collect --view +bun scripts/cli.ts web-probe observe analyze +``` + +## Triage Shape + +1. Separate shell/API/render: check public HTML/CSS/JS, `/api/overview`, `/api/runs`, then browser console/DOM render evidence. +2. Separate runner and web: runner Pod/PVC/API/report health is not the same as monitor-web rendering health. +3. Separate service rollout and target validation: Argo/runtime green only proves哨兵自身可用;HWLAB business recovery must come from observe/analyze report. +4. Separate single-sentinel and multi-sentinel: root registry shows all sentinels; each runner owns independent Pod/PVC/Service/report. A single monitor-web aggregation layer is a separate responsibility. + +## Architecture Preference + +Prefer Kubernetes-native discovery and isolation before inventing a custom control plane: + +- Labels/selectors identify sentinel runners. +- ClusterIP Services expose runner APIs. +- EndpointSlice or Service list/watch lets a monitor-web discover runner endpoints. +- PVC per runner preserves local report index. +- Deployment per runner is appropriate for long-lived observe sessions; CronJob is appropriate only for short stateless periodic probes. +- ConfigMap/Secret carry non-secret config and sourceRef-derived runtime material; output remains redacted. +- Prometheus/ServiceMonitor may scrape `/metrics` when the namespace already has that stack, but report drill-down should stay on runner HTTP APIs or a declared shared store. + +Read `references/full.md` for the current D601/v03 Web 哨兵 command matrix, dashboard triage checklist, and multi-sentinel target architecture. diff --git a/.agents/skills/unidesk-monitor/agents/openai.yaml b/.agents/skills/unidesk-monitor/agents/openai.yaml new file mode 100644 index 00000000..90f91eb8 --- /dev/null +++ b/.agents/skills/unidesk-monitor/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "UniDesk Monitor" + short_description: "UniDesk monitoring and Web sentinel operations" + default_prompt: "Use $unidesk-monitor to inspect HWLAB monitoring and Web sentinel status." diff --git a/.agents/skills/unidesk-monitor/references/full.md b/.agents/skills/unidesk-monitor/references/full.md new file mode 100644 index 00000000..ba8f6b91 --- /dev/null +++ b/.agents/skills/unidesk-monitor/references/full.md @@ -0,0 +1,107 @@ +# UniDesk Monitor Reference + +## Current Web Sentinel Surface + +Primary registry: + +```bash +bun scripts/cli.ts web-probe sentinel status --node D601 --lane v03 +``` + +Known D601/v03 sentinel ids: + +- `workbench-dsflash-go-tool-call-10x` +- `workbench-auth-session-switch-2users` + +Per-sentinel drill-down: + +```bash +bun scripts/cli.ts web-probe sentinel status --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x +bun scripts/cli.ts web-probe sentinel status --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users +bun scripts/cli.ts web-probe sentinel control-plane status --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x +bun scripts/cli.ts web-probe sentinel control-plane status --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users +``` + +Dashboard render and screenshot verification: + +```bash +bun scripts/cli.ts web-probe sentinel dashboard verify --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x +bun scripts/cli.ts web-probe sentinel dashboard screenshot --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x +bun scripts/cli.ts web-probe sentinel dashboard verify --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users +bun scripts/cli.ts web-probe sentinel dashboard screenshot --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users +``` + +Report drill-down: + +```bash +bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel --latest --view summary +bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel --latest --view findings +bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel --run --view trace-frame +``` + +Public dashboard paths: + +- `https://monitor.pikapython.com/` +- `https://monitor.pikapython.com/sentinels/workbench-auth-session-switch-2users/` + +Direct API probes for shell/API/render separation: + +```bash +curl -sS -D - https://monitor.pikapython.com/ -o /tmp/monitor-root.html +curl -sS -D - https://monitor.pikapython.com/api/overview -o /tmp/monitor-overview.json +curl -sS -D - https://monitor.pikapython.com/api/runs -o /tmp/monitor-runs.json +curl -sS -D - https://monitor.pikapython.com/sentinels/workbench-auth-session-switch-2users/api/overview -o /tmp/monitor-auth-overview.json +``` + +Use `web-probe script` with explicit `page.goto("https://monitor.pikapython.com/...")` only as one-off evidence until a dedicated dashboard verify/screenshot command exists. + +## YAML Ownership + +Root registry: + +- `config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.observability.webProbe.sentinels` + +Per-sentinel management YAML: + +- `config/hwlab-web-probe-sentinels/d601-v03/workbench-dsflash-go-tool-call-10x.yaml#sentinel` +- `config/hwlab-web-probe-sentinels/d601-v03/workbench-auth-session-switch-2users.yaml#sentinel` + +Typical config refs: + +- runtime: Deployment, Service, PVC, state root, SQLite, scheduler interval. +- workflow/scenarios: observed route, cadence, command sequence, prompt/report refs. +- promptSet: synthetic prompt ids and redaction policy. +- reportViews: default and drill-down views. +- publicExposure: `monitor.pikapython.com`, FRP, Caddy route prefix. +- cicd: source, image, GitOps, Argo and target validation. +- secrets: sourceRef and targetKey only; no values in stdout or issue bodies. + +## Dashboard Triage Checklist + +Classify a monitor page issue in this order: + +1. HTML shell loads: root status, asset links, `data-sentinel-id`, `data-base-path`, contract version. +2. API returns data: `/api/overview`, `/api/runs`, `/api/findings`, selected `/api/runs/{id}`. +3. Browser render executes: page errors, console errors, DOM rows, status summary, findings count. +4. Runtime service health: `/api/health`, `/metrics`, scheduler heartbeat, SQLite/PVC write probe. +5. Control-plane health: source, registry image, git-mirror, GitOps, Argo, Deployment/Service/PVC. + +Do not treat public root/CSS/JS 200 as dashboard success. Browser console and DOM render evidence are required. + +## Multi-Sentinel Target Architecture + +Current implementation has independent sentinel runner services, but each runner still serves its own dashboard. The target architecture should split: + +- `sentinel-runner`: one Deployment/PVC/Service per sentinel id; owns scheduler, observe wrapper, SQLite/report index, `/api/report`, `/api/overview`, `/metrics`. +- `monitor-web`: one Deployment/Service/public route per workspace or node/lane; owns dashboard shell, multi-sentinel registry view, aggregation/proxy APIs and public HTTPS exposure. + +Recommended Kubernetes-native mechanisms: + +- Label all runner Services and Pods with `unidesk.ai/web-probe-sentinel-id`, node, lane, workspace and component. +- Let monitor-web discover runners using Kubernetes Services or EndpointSlices with label selectors. +- Keep each runner Service `ClusterIP`; expose only monitor-web through declared `publicExposure`. +- Use runner Service DNS or Kubernetes discovery for aggregation, not hard-coded route prefixes. +- Keep per-runner PVCs for local report history unless a shared store is explicitly specified. +- Use CronJob only for short, stateless periodic probes. Keep Deployment for long-lived browser observers, maintenance API and progressive reports. + +This keeps runner isolation while giving users one monitor page for all sentinels in a workspace. diff --git a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md index 6e8781ab..232f0fcd 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md @@ -22,6 +22,7 @@ | 实现引用版本 | draft-2026-06-25-p0-web-probe-sentinel | | Dashboard 实现引用版本 | draft-2026-06-26-p8-web-probe-sentinel-recovery | | 多实例实现引用版本 | draft-2026-06-26-p9-multi-web-probe-sentinel | +| Monitor Web 聚合实现引用版本 | draft-2026-06-26-p10-monitor-web-aggregation | | 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | | 上级规格 | [PJ2026-010605 运维监控](PJ2026-010605-observability-monitoring.md) | | 关联规格 | [PJ2026-010401 Web工作台](PJ2026-010401-web-workbench.md)、[PJ2026-0104010803 Workbench唯一投影](PJ2026-0104010803-workbench-unique-projection.md)、[PJ2026-010403 API契约](PJ2026-010403-api-contract.md)、[PJ2026-010601 发布流水](PJ2026-010601-controlled-release.md)、[PJ2026-010602 源码同步](PJ2026-010602-source-sync.md)、[PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md)、[PJ2026-010604 公开入口](PJ2026-010604-public-entry.md)、[PJ2026-01060505 Workbench性能](PJ2026-01060505-workbench-performance.md) | @@ -114,6 +115,7 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin | PJ2026-0106050808 | 代码引用 | 本规格 6.8 | SPEC 头部标注和生成/配置追溯 | 规格治理 | 后续 PR 审计 | | PJ2026-0106050809 | Dashboard工作台 | 本规格 6.9 | overview、runs、findings、run detail、trace-frame viewer、前端分层 | 报告视图、常驻服务、Workbench性能 | 平台值守和问题分析 | | PJ2026-0106050810 | 多实例与账号切换 | 本规格 6.10 | sentinel registry、实例隔离、账号切换 command、route prefix 和 report label | YAML配置、Wrapper边界、安全隔离 | 多哨兵巡检、账号链路值守 | +| PJ2026-0106050811 | Monitor Web 聚合 | 本规格 6.11 | runner/web 职责拆分、单 monitor-web 聚合、Kubernetes discovery、Vue+TS 前端和 public exposure 收敛 | 多实例与账号切换、Dashboard工作台、发布集成 | `monitor.pikapython.com` 统一值守入口 | ### 5.1 目标架构图 @@ -593,6 +595,24 @@ Web 哨兵多实例必须以 node/lane root YAML 的 `observability.webProbe.sen 多实例 public exposure 复测必须通过受控 `web-probe screenshot` 或沉淀后的 command 远程截图能力完成,并把 PNG 保存到调用者 `/tmp` 下用于人工布局分析。修复 dashboard 布局问题后,复测截图必须覆盖 root dashboard 和至少一个 sentinel route prefix。 +### 6.11 OPS-SENTINEL-REQ-011 Monitor Web 聚合 + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-SENTINEL-REQ-011 | Monitor Web 聚合 | PJ2026-0106050811 Monitor Web 聚合 | [公开入口](PJ2026-010604-public-entry.md)、[YAML运维](PJ2026-010603-yaml-first-ops.md)、Dashboard工作台、多实例与账号切换 | + +P10 起,Web 哨兵运行面必须区分 `sentinel-runner` 与 `monitor-web`。`sentinel-runner` 继续以每个 sentinel id 一套 Deployment、Service、PVC、SQLite index、scheduler、observe wrapper、report API、health 和 metrics 的形态存在;它只负责采样、分析、索引和提供单哨兵渐进读取 API。`monitor-web` 是独立的展示和聚合层,负责 `monitor.pikapython.com` root、所有 enabled sentinel 的 registry 总览、聚合 overview/runs/findings、单哨兵 detail route、受控截图/验证入口和人类值守信息架构。runner 不得因为聚合需求而回退到共享 Pod、共享 PVC 或共享 SQLite。 + +`monitor-web` 配置必须是 YAML-first。node/lane、namespace、Deployment、Service、publicExposure、runner discovery label selector、read timeout、聚合 API、静态资源托管方式、RBAC、NetworkPolicy 和 Secret sourceRef 都必须来自 owning YAML 或 configRef。root `observability.webProbe.sentinels[]` 仍是 enabled sentinel registry;`monitor-web` 可以读取该 registry 与 Kubernetes Service/EndpointSlice discovery 结果做一致性校验,但不得把发现结果写回成第二 source of truth。 + +Runner discovery 优先使用 Kubernetes 原生对象:runner Service/Pod 必须带 `unidesk.ai/web-probe-sentinel-id`、node、lane、workspace 和 component label;`monitor-web` 使用 Service list/watch 或 EndpointSlice list/watch 按 label selector 发现 ClusterIP runner endpoint。Prometheus/ServiceMonitor 只用于低基数 metrics scrape,不承载 report drill-down,也不替代 runner HTTP API。runner Service 默认 ClusterIP;公网只暴露 `monitor-web`,不逐个暴露 runner。 + +`monitor-web` 前端目标技术栈为 Vue 3 + TypeScript + Vite,并与 HWLAB Cloud Web 的组件拆分、typed API client、状态管理、加载/空态/错误态、Markdown/代码块渲染和样式约定对齐。旧 `scripts/assets/web-probe-sentinel-dashboard/dashboard.js` vanilla `innerHTML` 实现只允许作为迁移前的短修和对照,不再作为 monitor-web 新能力的长期承载面。迁移前的短修仍必须保留 P8/P9 三栏、高信息密度、中文运维页面、渐进披露和 trace-frame 入口。 + +P10 dashboard 受控验收必须沉淀为 CLI 入口。`web-probe sentinel dashboard verify|screenshot` 或等价命令必须从 selected sentinel 的 YAML `publicExposure.publicBaseUrl` 解析 URL,通过远程浏览器执行 JS、采集 pageerror/console/requestfailed/DOM 行数/布局溢出和截图 SHA。`validate` 中的 public dashboard 200 只证明 HTML/CSS/JS 静态资源可达,不能替代浏览器渲染验收。 + +`monitor.pikapython.com` root 的目标首屏必须展示所有 enabled sentinels 的 latest status、latest run、red/amber/info 摘要、freshness、runner degraded 状态和 drill-down 链接;`/sentinels/` 必须展示单哨兵 runs/findings/detail/trace-frame。删除或重启一个 runner 只能让对应 sentinel 显示 degraded,不得让 root 变成空白或阻断其他 runner 数据。聚合 API 必须 bounded、分页、redacted,不打印 Secret、prompt 原文、provider payload、cookie、完整 stdout/stderr 或完整 report。 + ## 7. 过程控制 Web哨兵架构执行 issue 为 [#883](https://github.com/pikasTech/unidesk/issues/883)。阶段跟踪 issue 为 P0 [#885](https://github.com/pikasTech/unidesk/issues/885)、P1 [#886](https://github.com/pikasTech/unidesk/issues/886)、P2 [#887](https://github.com/pikasTech/unidesk/issues/887)、P3 [#888](https://github.com/pikasTech/unidesk/issues/888)、P4 [#889](https://github.com/pikasTech/unidesk/issues/889)、P5 [#890](https://github.com/pikasTech/unidesk/issues/890) 和 P6 [#891](https://github.com/pikasTech/unidesk/issues/891)。 @@ -608,3 +628,5 @@ P8 哨兵恢复执行 issue 为 [#971](https://github.com/pikasTech/unidesk/issu P8-P8 起,targetValidation 的 availability blocker 与 Workbench timing 架构治理必须分层。若同一 trace 在 terminal 前最后一帧仍为 running,随后 terminal commit 的 sealed `durationMs` 把可见 `totalElapsed` 小幅校正到更短值,且 drop 不超过 YAML `turnTimingSampleSlackSeconds`、final response 与完成行均已可见,则该现象作为 terminal-boundary timing correction 证据保留,不生成 `turn-timing-total-elapsed-decrease` red blocker。归零、running/running 下降、terminal 后增长、完成耗时与卡片耗时超出 slack、trace 乱序和完成行非最后仍保持 red;根因治理继续归 [HWLAB #2055](https://github.com/pikasTech/HWLAB/issues/2055) 和 [HWLAB #2125](https://github.com/pikasTech/HWLAB/issues/2125),不得在 UI、CLI renderer 或 analyzer 中做读侧 repair。 P9 多实例巡检与账号切换链路执行 issue 为 [#1017](https://github.com/pikasTech/unidesk/issues/1017)。P9 closeout 必须回写:SPEC P9 引用、registry 和两条 sentinel drill-down、旧 dsflash canary 迁移验证、账号切换 workflow/Secret sourceRef 验证、独立 Deployment/PVC/Service/SQLite/GitOps/Argo/public route prefix 证据、submit/command 失败处理、非阻塞计时告警证据、远程 PNG 截图布局复测,以及未完成阶段是否已拆出后续 issue。 + +P10 monitor-web 聚合执行 issue 为 [#1056](https://github.com/pikasTech/unidesk/issues/1056)。P10 closeout 必须回写:SPEC P10 引用、runner/web 职责拆分状态、Vue+TS monitor-web 迁移边界或短修边界、root 与至少一个 route prefix 的 browser render 证据、`web-probe sentinel dashboard verify|screenshot` 证据、publicExposure 和 runtime provenance、单哨兵 API 兼容性、未完成 monitor-web 架构项是否拆出后续 issue,以及 `unidesk-monitor` skill 是否记录当前操作面。 diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css index 973b5891..36d27f4e 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css @@ -299,6 +299,79 @@ select { margin-left: auto; } +.sentinel-registry-strip { + display: grid; + grid-template-columns: minmax(160px, 220px) 1fr; + align-items: center; + gap: 10px; + margin-bottom: 10px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: #ffffff; +} + +.sentinel-registry-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.sentinel-registry-copy strong { + font-size: 13px; + line-height: 1.25; +} + +.sentinel-registry-copy span { + color: var(--muted); + font-size: 11px; +} + +.sentinel-registry-links { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.sentinel-registry-link { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 100%; + min-height: 28px; + padding: 4px 8px; + border: 1px solid #d8e0ea; + border-radius: 6px; + background: #f8fafc; + color: #344054; + font-size: 12px; + font-weight: 650; + text-decoration: none; +} + +.sentinel-registry-link.current { + border-color: #a7c5f9; + background: #edf5ff; + color: #1d4ed8; +} + +.sentinel-registry-link.disabled { + opacity: 0.62; +} + +.sentinel-registry-link span { + overflow-wrap: anywhere; +} + +.sentinel-registry-link small { + color: var(--muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} + .check-summary-pill { min-height: 26px; padding: 3px 10px; @@ -1195,6 +1268,10 @@ select { margin-left: 0; } + .sentinel-registry-strip { + grid-template-columns: 1fr; + } + .runs-filter { flex-wrap: wrap; } diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js index 915dc870..68f93f75 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js @@ -73,6 +73,8 @@ const refs = { copyToast: document.getElementById("copy-toast"), }; +const DETAIL_TABS = ["overview", "findings", "turn", "trace", "evidence"]; + const state = { loading: false, selectedRunId: null, @@ -99,8 +101,6 @@ const state = { pausedByInteraction: false, }; -const DETAIL_TABS = ["overview", "findings", "turn", "trace", "evidence"]; - const dashboardLimits = { timeline: { mobile: 6, tablet: 9, desktop: 16 }, runs: { mobile: 8, tablet: 12, desktop: 30 }, diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index b32d9526..fb9bec48 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -62,6 +62,8 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe observe analyze webobs-xxxx", "bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run", "bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users", + "bun scripts/cli.ts web-probe sentinel dashboard verify --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x", + "bun scripts/cli.ts web-probe sentinel dashboard screenshot --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users", "bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --confirm --wait --release-id ", ], actions: { @@ -69,7 +71,7 @@ export function hwlabNodeWebProbeHelp(): Record { script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.", screenshot: "Capture a no-auth or public page through the selected node/lane remote browser and download PNG artifacts to the caller /tmp by default.", observe: "Start, inspect, control, stop, collect, and analyze a long-running observer that writes JSONL artifacts.", - sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, maintenance and report views.", + sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views.", }, notes: [ "Default URL, browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.", @@ -78,7 +80,7 @@ export function hwlabNodeWebProbeHelp(): Record { "After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", "collect views render bounded summaries from existing artifacts and do not create a second source of truth.", "analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.", - "When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/report require `--sentinel `; plan/status without it show the registry drill-down.", + "When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/dashboard/report require `--sentinel `; plan/status without it show the registry drill-down.", "Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.", ], }; diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index 3bc5fd92..036bf20c 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -1,6 +1,7 @@ // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. +// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation. // Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel. import { createHash, randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; @@ -8,6 +9,7 @@ import { join } from "node:path"; import { repoRoot, rootPath } from "./config"; import { runCommand, type CommandResult } from "./command"; import { startJob } from "./jobs"; +import { transPath } from "./hwlab-node/runtime-common"; import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config"; import { requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver"; import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; @@ -17,6 +19,7 @@ export type WebProbeSentinelConfigAction = "plan" | "status"; export type WebProbeSentinelImageAction = "status" | "build"; export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current"; export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop"; +export type WebProbeSentinelDashboardAction = "verify" | "screenshot"; export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary"; export type WebProbeSentinelOptions = @@ -89,6 +92,22 @@ export type WebProbeSentinelOptions = readonly sampleSeq: number | null; readonly raw: boolean; readonly timeoutSeconds: number; + } + | { + readonly kind: "dashboard"; + readonly action: WebProbeSentinelDashboardAction; + readonly node: string; + readonly lane: string; + readonly sentinelId: string | null; + readonly viewport: string; + readonly localDir: string; + readonly name: string | null; + readonly timeoutMs: number; + readonly waitTimeoutMs: number; + readonly timeoutSeconds: number; + readonly commandTimeoutSeconds: number; + readonly fullPage: boolean; + readonly raw: boolean; }; interface SentinelCicdState { @@ -181,6 +200,7 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: if (options.kind === "control-plane") return runSentinelControlPlane(state, options); if (options.kind === "maintenance") return runSentinelMaintenance(state, options); if (options.kind === "validate") return runSentinelValidate(state, options); + if (options.kind === "dashboard") return runSentinelDashboard(state, options); return runSentinelReport(state, options); } @@ -1747,6 +1767,296 @@ function runSentinelReport(state: SentinelCicdState, options: Extract): RenderedCliResult { + const command = `web-probe sentinel dashboard ${options.action}`; + const result = probeSentinelDashboardBrowser(state, options); + return rendered(result.ok === true, command, options.raw ? JSON.stringify(result, null, 2) : renderDashboardResult(result)); +} + +function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extract): Record { + const publicBaseUrl = stringAt(state.publicExposure, "publicBaseUrl").replace(/\/$/u, ""); + const [widthRaw, heightRaw] = options.viewport.split("x"); + const screenshotName = options.action === "screenshot" ? dashboardScreenshotName(options, state) : ""; + const script = [ + "set -eu", + `export UNIDESK_SENTINEL_DASHBOARD_URL=${shellQuote(`${publicBaseUrl}/`)}`, + `export UNIDESK_SENTINEL_DASHBOARD_SCREENSHOT="$UNIDESK_PLAYWRIGHT_REMOTE_DIR"/${shellQuote(screenshotName)}`, + `export UNIDESK_SENTINEL_DASHBOARD_CAPTURE=${shellQuote(options.action === "screenshot" ? "1" : "0")}`, + `export UNIDESK_SENTINEL_DASHBOARD_WIDTH=${shellQuote(widthRaw ?? "1440")}`, + `export UNIDESK_SENTINEL_DASHBOARD_HEIGHT=${shellQuote(heightRaw ?? "900")}`, + `export UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`, + `export UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE=${shellQuote(options.fullPage ? "1" : "0")}`, + "if command -v chromium >/dev/null 2>&1; then", + " export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=$(command -v chromium)", + "elif command -v chromium-browser >/dev/null 2>&1; then", + " export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=$(command -v chromium-browser)", + "elif command -v google-chrome >/dev/null 2>&1; then", + " export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=$(command -v google-chrome)", + "else", + " export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=", + "fi", + "cat > \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\" <<'WEB_PROBE_SENTINEL_DASHBOARD_JS'", + sentinelDashboardBrowserModule(), + "WEB_PROBE_SENTINEL_DASHBOARD_JS", + "bun \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\"", + ].join("\n"); + const route = `${state.spec.nodeId}:${state.spec.workspace}`; + const result = runCommand([ + transPath(), + route, + "playwright", + "--local-dir", + options.localDir, + "--wait-timeout-ms", + String(options.waitTimeoutMs), + "--inactivity-timeout-ms", + "30000", + ], repoRoot, { input: script, timeoutMs: options.commandTimeoutSeconds * 1000 }); + const transport = record(parseJsonObject(result.stdout)); + const remote = record(transport.remote); + const page = parseDashboardBrowserPayload(typeof remote.stdoutTail === "string" ? remote.stdoutTail : ""); + const artifacts = Array.isArray(transport.artifacts) ? transport.artifacts.map(record).map(compactDashboardArtifact) : []; + const screenshot = artifacts.find((artifact) => typeof artifact.localPath === "string" && String(artifact.localPath).endsWith(".png")) ?? null; + const browserOk = page?.ok === true; + const screenshotOk = options.action === "verify" || screenshot !== null && screenshot.verified === true; + const ok = result.exitCode === 0 && transport.ok === true && browserOk && screenshotOk; + return { + ok, + status: ok ? "pass" : "blocked", + command: `web-probe sentinel dashboard ${options.action}`, + node: state.spec.nodeId, + lane: state.spec.lane, + sentinelId: state.sentinelId, + publicUrl: `${publicBaseUrl}/`, + route, + viewport: options.viewport, + page, + screenshot, + artifacts, + artifactCount: artifacts.length, + remote: { + exitCode: remote.exitCode ?? null, + remoteDir: remote.remoteDir ?? null, + stdoutTail: ok ? "" : typeof remote.stdoutTail === "string" ? remote.stdoutTail.slice(-1200) : "", + stderrTail: ok ? "" : typeof remote.stderrTail === "string" ? remote.stderrTail.slice(-1200) : "", + }, + transport: { + ok: transport.ok ?? null, + runId: transport.runId ?? null, + artifactCount: transport.artifactCount ?? null, + expectedArtifactCount: transport.expectedArtifactCount ?? null, + downloadFailure: transport.downloadFailure ?? null, + }, + result: compactCommand(result), + degradedReason: ok ? null : dashboardDegradedReason(result, transport, page, screenshotOk), + valuesRedacted: true, + }; +} + +function sentinelDashboardBrowserModule(): string { + return String.raw`import { chromium } from "playwright"; + +const url = process.env.UNIDESK_SENTINEL_DASHBOARD_URL; +const screenshotPath = process.env.UNIDESK_SENTINEL_DASHBOARD_SCREENSHOT || ""; +const captureScreenshot = process.env.UNIDESK_SENTINEL_DASHBOARD_CAPTURE === "1"; +const width = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_WIDTH || 1440); +const height = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_HEIGHT || 900); +const timeout = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS || 30000); +const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0"; +const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || ""; + +if (!url) throw new Error("missing dashboard URL"); + +const consoleMessages = []; +const pageErrors = []; +const requestFailures = []; +const browser = await chromium.launch({ + headless: true, + args: ["--disable-gpu", "--no-sandbox"], + ...(executablePath ? { executablePath } : {}), +}); +const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1, isMobile: width <= 560 }); +const page = await context.newPage(); +page.on("console", (message) => { + if (consoleMessages.length < 30) consoleMessages.push({ type: message.type(), text: message.text().slice(0, 300) }); +}); +page.on("pageerror", (error) => { + if (pageErrors.length < 20) pageErrors.push({ message: String(error?.message || error).slice(0, 500) }); +}); +page.on("requestfailed", (request) => { + if (requestFailures.length < 20) requestFailures.push({ url: request.url().slice(0, 240), method: request.method(), failure: request.failure()?.errorText || null }); +}); + +let httpStatus = null; +let navigationError = null; +try { + const response = await page.goto(url, { timeout, waitUntil: "domcontentloaded" }); + httpStatus = response?.status() ?? null; + await page.waitForLoadState("networkidle", { timeout: Math.min(15000, timeout) }).catch(() => {}); + await page.waitForFunction(() => { + const root = document.querySelector("#sentinel-dashboard"); + if (!root) return false; + const error = document.querySelector("#error-banner"); + const runs = document.querySelectorAll("#runs-body tr").length; + const statusText = document.querySelector("#status-pill")?.textContent || ""; + return (error && !error.hidden) || runs > 0 || (statusText.trim() && statusText.trim() !== "空闲"); + }, null, { timeout: Math.min(15000, timeout) }).catch(() => {}); + await page.waitForTimeout(500); +} catch (error) { + navigationError = String(error?.message || error).slice(0, 500); +} + +if (captureScreenshot && screenshotPath) { + await page.screenshot({ path: screenshotPath, fullPage, animations: "disabled" }).catch((error) => { + pageErrors.push({ message: "screenshot failed: " + String(error?.message || error).slice(0, 400) }); + }); +} + +const dom = await page.evaluate(() => { + const visible = (element) => Boolean(element && !element.hidden); + const text = (selector) => String(document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim(); + const root = document.querySelector("#sentinel-dashboard"); + const error = document.querySelector("#error-banner"); + const loading = document.querySelector("#loading-banner"); + const doc = document.documentElement; + const body = document.body; + const viewport = { width: window.innerWidth, height: window.innerHeight }; + const documentSize = { + width: Math.max(doc.scrollWidth, body?.scrollWidth || 0), + height: Math.max(doc.scrollHeight, body?.scrollHeight || 0), + }; + const overflow = []; + let overflowCount = 0; + for (const element of Array.from(document.querySelectorAll("body *"))) { + const rect = element.getBoundingClientRect(); + const overflowRight = rect.right - viewport.width; + const overflowLeft = -rect.left; + if (overflowRight > 1 || overflowLeft > 1) { + overflowCount += 1; + if (overflow.length < 5) { + overflow.push({ + tag: element.tagName.toLowerCase(), + className: String(element.className || "").slice(0, 80), + text: String(element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80), + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + overflowRight: Math.max(0, Math.round(overflowRight)), + overflowLeft: Math.max(0, Math.round(overflowLeft)), + }); + } + } + } + return { + shell: Boolean(root), + dataset: root ? { + node: root.getAttribute("data-node"), + lane: root.getAttribute("data-lane"), + sentinelId: root.getAttribute("data-sentinel-id"), + basePath: root.getAttribute("data-base-path"), + contractVersion: root.getAttribute("data-contract-version"), + } : {}, + title: document.title, + finalUrl: window.location.href, + statusText: text("#status-pill"), + subtitle: text("#sentinel-subtitle"), + summaryText: text("#status-summary"), + runRows: document.querySelectorAll("#runs-body tr").length, + findingItems: document.querySelectorAll("#findings-list > *").length, + detailTabs: document.querySelectorAll("#detail-tabs [data-detail-tab]").length, + timelineItems: document.querySelectorAll("#run-timeline > *").length, + loadingVisible: visible(loading), + errorVisible: visible(error), + errorText: visible(error) ? text("#error-banner").slice(0, 500) : "", + layout: { + viewport, + documentSize, + horizontalOverflow: documentSize.width > viewport.width + 1, + overflowCount, + overflow, + }, + }; +}); + +const consoleErrors = consoleMessages.filter((item) => item.type === "error"); +const ok = !navigationError + && httpStatus !== null + && httpStatus >= 200 + && httpStatus < 300 + && dom.shell === true + && dom.errorVisible !== true + && dom.runRows > 0 + && pageErrors.length === 0; + +console.log("__WEB_PROBE_SENTINEL_DASHBOARD_JSON__" + JSON.stringify({ + ok, + url, + httpStatus, + navigationError, + executablePath: executablePath || null, + viewport: { width, height }, + screenshotPath: captureScreenshot ? screenshotPath : null, + dom, + consoleCount: consoleMessages.length, + consoleErrorCount: consoleErrors.length, + pageErrorCount: pageErrors.length, + requestFailureCount: requestFailures.length, + consoleMessages: consoleMessages.slice(0, 8), + pageErrors: pageErrors.slice(0, 8), + requestFailures: requestFailures.slice(0, 8), + valuesRedacted: true, +})); + +await context.close().catch(() => {}); +await browser.close().catch(() => {}); +`; +} + +function parseDashboardBrowserPayload(textValue: string): Record | null { + const marker = "__WEB_PROBE_SENTINEL_DASHBOARD_JSON__"; + const index = textValue.lastIndexOf(marker); + if (index < 0) return null; + try { + return record(JSON.parse(textValue.slice(index + marker.length).trim())); + } catch { + return null; + } +} + +function dashboardScreenshotName(options: Extract, state: SentinelCicdState): string { + const raw = options.name ?? `sentinel-dashboard-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}-${state.sentinelId}.png`; + const safe = raw.replace(/[^A-Za-z0-9._-]+/gu, "-").slice(0, 120); + return safe.endsWith(".png") ? safe : `${safe}.png`; +} + +function compactDashboardArtifact(artifact: Record): Record { + const transfer = record(artifact.transfer); + return { + remotePath: typeof artifact.remotePath === "string" ? artifact.remotePath : null, + localPath: typeof artifact.localPath === "string" ? artifact.localPath : null, + bytes: Number.isFinite(Number(artifact.bytes)) ? Number(artifact.bytes) : null, + sha256: typeof artifact.sha256 === "string" ? artifact.sha256 : null, + verified: artifact.verified === true, + transfer: Object.keys(transfer).length === 0 ? null : { + strategy: transfer.strategy ?? null, + transport: transfer.transport ?? null, + chunks: transfer.chunks ?? null, + elapsedMs: transfer.elapsedMs ?? null, + throughputBytesPerSecond: transfer.throughputBytesPerSecond ?? null, + }, + }; +} + +function dashboardDegradedReason(result: CommandResult, transport: Record, page: Record | null, screenshotOk: boolean): string { + if (result.timedOut) return "sentinel-dashboard-command-timeout"; + if (transport.ok !== true) return "sentinel-dashboard-transport-failed"; + if (page === null) return "sentinel-dashboard-browser-output-missing"; + if (page.ok !== true) return "sentinel-dashboard-render-failed"; + if (!screenshotOk) return "sentinel-dashboard-screenshot-missing"; + return "sentinel-dashboard-unknown"; +} + function renderAsyncP5Job(state: SentinelCicdState, subcommand: readonly string[], timeoutSeconds: number, releaseId: string | null, reason: string | null, quickVerify: boolean): RenderedCliResult { const args = ["web-probe", "sentinel", ...subcommand, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]; if (releaseId !== null) args.push("--release-id", releaseId); @@ -3461,6 +3771,55 @@ function renderValidateResult(result: Record): string { ].join("\n"); } +function renderDashboardResult(result: Record): string { + const page = record(result.page); + const dom = record(page.dom); + const dataset = record(dom.dataset); + const layout = record(dom.layout); + const screenshot = record(result.screenshot); + const remote = record(result.remote); + const transport = record(result.transport); + const degradedReason = result.degradedReason ?? null; + return [ + String(result.command), + "", + table(["NODE", "LANE", "SENTINEL", "STATUS", "URL"], [[result.node, result.lane, result.sentinelId, result.ok === true ? "pass" : "blocked", result.publicUrl]]), + "", + table(["HTTP", "SHELL", "RUN_ROWS", "FINDINGS", "TABS", "ERRORS", "CONSOLE_ERR", "REQ_FAIL"], [[ + page.httpStatus ?? "-", + dom.shell, + dom.runRows, + dom.findingItems, + dom.detailTabs, + page.pageErrorCount, + page.consoleErrorCount, + page.requestFailureCount, + ]]), + "", + table(["TITLE", "STATUS_TEXT", "CONTRACT", "BASE_PATH"], [[dom.title, dom.statusText, dataset.contractVersion, dataset.basePath ?? "-"]]), + "", + table(["VIEWPORT", "DOC", "H_OVERFLOW", "OVERFLOW_COUNT"], [[ + result.viewport, + `${record(layout.documentSize).width ?? "-"}x${record(layout.documentSize).height ?? "-"}`, + layout.horizontalOverflow, + layout.overflowCount, + ]]), + "", + Object.keys(screenshot).length === 0 + ? "SCREENSHOT\n-" + : table(["LOCAL_PATH", "BYTES", "SHA256", "VERIFIED"], [[screenshot.localPath, screenshot.bytes, short(screenshot.sha256), screenshot.verified]]), + "", + degradedReason === null ? "BLOCKER\n-" : table(["CODE", "REMOTE_EXIT", "TRANSPORT"], [[degradedReason, remote.exitCode, transport.ok]]), + "", + "NEXT", + ` screenshot: bun scripts/cli.ts web-probe sentinel dashboard screenshot --node ${result.node} --lane ${result.lane} --sentinel ${result.sentinelId}`, + ` validate: bun scripts/cli.ts web-probe sentinel validate --node ${result.node} --lane ${result.lane} --sentinel ${result.sentinelId}`, + "", + "DISCLOSURE", + " dashboard verify uses the YAML publicExposure URL and remote browser execution; it does not start a sentinel run or inspect provider payloads.", + ].join("\n"); +} + function renderReportResult(result: Record): string { const report = record(result.report); const body = record(report.bodyJson); diff --git a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts index fcc2bfe9..8f48f312 100644 --- a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts +++ b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts @@ -1,5 +1,6 @@ // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. +// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation. // Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend. import { readFileSync } from "node:fs"; import { rootPath } from "./config"; @@ -8,16 +9,17 @@ interface DashboardShellConfig { readonly node: string; readonly lane: string; readonly sentinelId: string; - readonly plan: { readonly ok: boolean }; + readonly plan: { readonly ok: boolean; readonly sentinels?: readonly Record[] }; readonly publicExposure: Record; } const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard"; -const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density"; +const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p10-monitor-web-aggregation"; export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string { const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? ""; const basePath = publicBasePath(publicOrigin); + const registryHtml = renderSentinelRegistryStrip(config, basePath); return ` @@ -77,6 +79,8 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig + ${registryHtml} + @@ -228,6 +232,35 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig `; } +function renderSentinelRegistryStrip(config: DashboardShellConfig, basePath: string): string { + const rows = Array.isArray(config.plan.sentinels) ? config.plan.sentinels.map((item) => ({ + id: stringOrNull(item.id) ?? "", + enabled: item.enabled !== false, + })).filter((item) => item.id.length > 0) : []; + if (rows.length <= 1) return ""; + return `
+
+ 哨兵入口 + 当前 workspace 共 ${rows.length} 条 +
+ +
`; +} + +function renderSentinelRegistryLink(id: string, enabled: boolean, current: boolean, basePath: string): string { + const href = current + ? `${basePath || "/"}` + : id === "workbench-dsflash-go-tool-call-10x" + ? "/" + : `/sentinels/${encodeURIComponent(id)}/`; + return ` + ${escapeHtml(id)} + ${current ? "当前" : enabled ? "查看" : "停用"} + `; +} + function publicBasePath(publicBaseUrl: string): string { try { const path = new URL(publicBaseUrl).pathname.replace(/\/+$/u, ""); diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 503eaa66..ed96fede 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -22,7 +22,7 @@ import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectVie import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render"; import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper"; import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render"; -import { runWebProbeSentinelCommand, type WebProbeSentinelOptions, type WebProbeSentinelReportView } from "../hwlab-node-web-sentinel-cicd"; +import { runWebProbeSentinelCommand, type WebProbeSentinelDashboardAction, type WebProbeSentinelOptions, type WebProbeSentinelReportView } from "../hwlab-node-web-sentinel-cicd"; import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help"; import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary"; import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql"; @@ -46,9 +46,10 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe && sentinelActionRaw !== "control-plane" && sentinelActionRaw !== "validate" && sentinelActionRaw !== "maintenance" + && sentinelActionRaw !== "dashboard" && sentinelActionRaw !== "report" ) { - throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|validate|maintenance|report --node NODE --lane vNN [--dry-run|--confirm]"); + throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|validate|maintenance|dashboard|report --node NODE --lane vNN [--dry-run|--confirm]"); } assertKnownOptions(args, new Set([ "--node", @@ -63,7 +64,13 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe "--sample-seq", "--sentinel", "--sentinel-id", - ]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest"])); + "--viewport", + "--local-dir", + "--name", + "--timeout-ms", + "--wait-timeout-ms", + "--command-timeout-seconds", + ]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest", "--full-page", "--no-full-page"])); const node = requiredOption(args, "--node"); assertNodeId(node); const lane = requiredOption(args, "--lane"); @@ -109,6 +116,27 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe }; } else if (sentinelActionRaw === "validate") { sentinel = { kind: "validate", action: "validate", node, lane, sentinelId, dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, quickVerify: args.includes("--quick-verify") }; + } else if (sentinelActionRaw === "dashboard") { + const dashboardAction = parseWebProbeSentinelDashboardAction(args[1]); + const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 120000); + const waitTimeoutMs = positiveIntegerOption(args, "--wait-timeout-ms", Math.max(90000, timeoutMs + 30000), 600000); + const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.ceil(waitTimeoutMs / 1000) + 45, 900); + sentinel = { + kind: "dashboard", + action: dashboardAction, + node, + lane, + sentinelId, + viewport: parseWebProbeSentinelDashboardViewport(optionValue(args, "--viewport") ?? "1440x900"), + localDir: optionValue(args, "--local-dir") ?? "/tmp", + name: optionValue(args, "--name") ?? null, + timeoutMs, + waitTimeoutMs, + timeoutSeconds: commandTimeoutSeconds, + commandTimeoutSeconds, + fullPage: !args.includes("--no-full-page"), + raw: args.includes("--raw"), + }; } else { const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary"); const latest = args.includes("--latest"); @@ -140,6 +168,20 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe }; } +function parseWebProbeSentinelDashboardAction(value: string | undefined): WebProbeSentinelDashboardAction { + if (value === "verify" || value === "screenshot") return value; + throw new Error("web-probe sentinel dashboard usage: dashboard verify|screenshot --node NODE --lane vNN --sentinel "); +} + +function parseWebProbeSentinelDashboardViewport(value: string): string { + if (!/^[1-9][0-9]{1,4}x[1-9][0-9]{1,4}$/u.test(value)) throw new Error(`web-probe sentinel dashboard --viewport must look like 1440x900, got ${value}`); + const [widthRaw, heightRaw] = value.split("x"); + const width = Number(widthRaw); + const height = Number(heightRaw); + if (width < 240 || width > 7680 || height < 240 || height > 4320) throw new Error(`web-probe sentinel dashboard --viewport out of range: ${value}`); + return value; +} + function parseWebProbeSentinelReportView(value: string): WebProbeSentinelReportView { if (value === "summary" || value === "turn-summary" || value === "findings" || value === "trace-frame" || value === "auth-session-switch-summary") return value; throw new Error(`web-probe sentinel report --view must be summary, turn-summary, findings, trace-frame, or auth-session-switch-summary; got ${value}`);