fix(web-probe): recover monitor dashboard render

This commit is contained in:
Codex
2026-06-26 16:06:25 +00:00
parent 2d953cc911
commit 4490278cb6
10 changed files with 713 additions and 9 deletions
+58
View File
@@ -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` 和受控 CLIYAML 正规化走 `$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 <id>
bun scripts/cli.ts web-probe sentinel control-plane status --node D601 --lane v03 --sentinel <id>
bun scripts/cli.ts web-probe sentinel validate --node D601 --lane v03 --sentinel <id>
bun scripts/cli.ts web-probe sentinel dashboard verify --node D601 --lane v03 --sentinel <id>
bun scripts/cli.ts web-probe sentinel dashboard screenshot --node D601 --lane v03 --sentinel <id>
bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel <id> --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 <observerId> --type <command>
bun scripts/cli.ts web-probe observe collect <observerId> --view <view>
bun scripts/cli.ts web-probe observe analyze <observerId>
```
## 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.
@@ -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."
@@ -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 <id> --latest --view summary
bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel <id> --latest --view findings
bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel <id> --run <runId> --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.
@@ -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/<id>` 必须展示单哨兵 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 是否记录当前操作面。
@@ -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;
}
@@ -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 },
+4 -2
View File
@@ -62,6 +62,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"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 <id>",
],
actions: {
@@ -69,7 +71,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
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<string, unknown> {
"After observe start, prefer observe status|command|stop|collect|analyze <id> 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 <id>`; 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 <id>`; 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.",
],
};
+359
View File
@@ -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<WebProbeSe
return rendered(report.ok && body.ok !== false, command, options.raw ? JSON.stringify(rawPayload, null, 2) : renderedText);
}
function runSentinelDashboard(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "dashboard" }>): 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<WebProbeSentinelOptions, { kind: "dashboard" }>): Record<string, unknown> {
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<string, unknown> | 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<WebProbeSentinelOptions, { kind: "dashboard" }>, 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<string, unknown>): Record<string, unknown> {
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<string, unknown>, page: Record<string, unknown> | 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, unknown>): string {
].join("\n");
}
function renderDashboardResult(result: Record<string, unknown>): 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, unknown>): string {
const report = record(result.report);
const body = record(report.bodyJson);
@@ -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<string, unknown>[] };
readonly publicExposure: Record<string, unknown>;
}
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 `<!doctype html>
<html lang="zh-CN">
<head>
@@ -77,6 +79,8 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
<span class="summary-item summary-checks" id="summary-checks" hidden></span>
</section>
${registryHtml}
<section id="loading-banner" class="banner banner-muted" hidden>加载中</section>
<section id="error-banner" class="banner banner-danger" hidden></section>
@@ -228,6 +232,35 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
</html>`;
}
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 `<section class="sentinel-registry-strip" aria-label="多哨兵入口">
<div class="sentinel-registry-copy">
<strong>哨兵入口</strong>
<span>当前 workspace 共 ${rows.length} 条</span>
</div>
<div class="sentinel-registry-links">
${rows.map((item) => renderSentinelRegistryLink(item.id, item.enabled, item.id === config.sentinelId, basePath)).join("")}
</div>
</section>`;
}
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 `<a class="sentinel-registry-link${current ? " current" : ""}${enabled ? "" : " disabled"}" href="${escapeAttr(href)}">
<span>${escapeHtml(id)}</span>
<small>${current ? "当前" : enabled ? "查看" : "停用"}</small>
</a>`;
}
function publicBasePath(publicBaseUrl: string): string {
try {
const path = new URL(publicBaseUrl).pathname.replace(/\/+$/u, "");
+45 -3
View File
@@ -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 <id>");
}
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}`);