fix(web-probe): recover monitor dashboard render
This commit is contained in:
@@ -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 <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 },
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user