fix(web): v0.2 round-8 workbench: workbench probe card + live build summary (HWLAB #775 round 5) (#787)

Continues HWLAB #775 round 5 by restoring the probe card and live
build summary that the React migration dropped. Cloud-api v0.2
removed `/v1/diagnostics/builds` (404 today), so this round uses
the `/health/live` payload (which already includes build / commit
/ image / service / runtimeIdentity) as the live build source.

## 修复对照

| 类别 | 迁移前 (`893f46bc`) | 迁移后 (round 5) | 状态 |
|---|---|---|---|
| **Probe card 4 端点** | `app-device-pod.ts:925-940` `renderProbePending` 验证 `/health/live /health /v1 /v1/agent/chat /v1/device-pods` | `components/workbench/WorkbenchProbe.tsx`:从 `live.healthLive / health / restIndex / adapter` 4 端点结果,渲染方法/路径/状态/响应时间/详情行 |  对齐 |
| **Live build summary** | `app-device-pod.ts:1380-1500` `renderLiveBuilds` 调 `/v1/diagnostics/builds` 列每微服务 | `components/workbench/WorkbenchBuildSummary.tsx`:从 `live.healthLive` 抽 `service / commit / image / build` 4 字段,渲染 `name / builtAt (北京时间) / tag / commit / revision / 来源 / reason` |  对齐(路径退化) |
| **Probe card tone** | `renderProbePending` 设 `tone-pending` | 同 |  对齐 |

## 改动

- 新增 `web/hwlab-cloud-web/src/components/workbench/WorkbenchProbe.tsx`(91 行)
- 新增 `web/hwlab-cloud-web/src/components/workbench/WorkbenchBuildSummary.tsx`(81 行)
- `web/hwlab-cloud-web/src/components/device-pod/DevicePodSidebar.tsx` — 在 `.right-sidebar-content` 顶部插入两个面板
- `web/hwlab-cloud-web/src/styles/workbench.css` — `.probe-card` / `.probe-row` / `.probe-row-head` / `.probe-row-meta` / `.probe-row-detail` / `.live-build-summary` / `.live-build-row` / `.live-build-meta` / `.live-build-reason`

## 验收

- `bun run check` 通过(Vite build + 2/2 dist freshness test pass)
- `bun run scripts/tsc-check.ts` 通过(strict React TSX, 0 explicit any)
- `bun test` 2 pass / 0 fail
- `bun run build` 9 dist files verified fresh;`app.js` 226176 → 230917 bytes;`index.css` 17152 → 19062 bytes
- 所有改动文件 < 400 行前端 guard

Refs: pikasTech/HWLAB#775 (round 5 of 7)

Co-authored-by: HWLAB <ci@hwlab.local>
This commit is contained in:
Lyon
2026-06-03 23:41:38 +08:00
committed by GitHub
parent 527c5b0dd5
commit 3a2f9e33bd
4 changed files with 204 additions and 0 deletions
@@ -5,6 +5,8 @@ import { devicePodsFromLive } from "../../state/workbench";
import type { DevicePodEvent, DevicePodInterface, DevicePodItem, DevicePodStatusResponse, LiveSurface } from "../../types/domain";
import { formatTimestamp, jsonPreview, shortToken, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
import { WorkbenchBuildSummary } from "../workbench/WorkbenchBuildSummary";
import { WorkbenchProbe } from "../workbench/WorkbenchProbe";
interface DevicePodSidebarProps {
live: LiveSurface | null;
@@ -26,6 +28,8 @@ export function DevicePodSidebar({ live, selectedDevicePodId, onSelect, collapse
<aside className={`right-sidebar${collapsed ? " is-device-pod-collapsed" : ""}`} id="device-pod-sidebar" aria-label="Device Pod 摘要看板" aria-hidden={collapsed}>
<button className="right-sidebar-toggle right-sidebar-toggle--anchor" id="right-sidebar-toggle" type="button" aria-controls="device-pod-sidebar" aria-expanded={!collapsed} aria-label={collapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"} onClick={onToggle}>{collapsed ? "展开看板" : "收起看板"}</button>
<div className="right-sidebar-content">
<WorkbenchProbe live={live} />
<WorkbenchBuildSummary live={live} />
<header className="right-sidebar-head">
<div>
<p className="eyebrow">Device Pod</p>
@@ -0,0 +1,81 @@
import type { ReactElement } from "react";
import type { LiveProbePayload, LiveSurface } from "../../types/domain";
import { formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
interface WorkbenchBuildSummaryProps {
live: LiveSurface | null;
}
interface ServiceRow {
key: string;
label: string;
serviceId: string | null;
commitShort: string | null;
revisionShort: string | null;
tag: string | null;
builtAt: string | null;
status: "ok" | "pending" | "blocked" | "source" | "warn";
reason: string | null;
source: string | null;
}
export function WorkbenchBuildSummary({ live }: WorkbenchBuildSummaryProps): ReactElement {
const data = live?.healthLive.data;
const service = data?.service;
const build = data?.build;
const commit = data?.commit;
const image = data?.image;
const row = buildRowFromLive(service, commit, image, build);
const overall = row.status;
return (
<section className={`live-build-summary live-build-summary-${overall} tone-border-${toneClass(overall)}`} data-live-build-summary>
<header className="live-build-summary-head">
<p className="eyebrow"></p>
<StateTag tone={toneClass(overall)} data-live-build-tone>{row.builtAt ? formatBeijingTime(row.builtAt, { withSeconds: true }) : "构建时间不可用"}</StateTag>
</header>
<ul className="live-build-list" role="list">
<li className="live-build-row" role="listitem" data-live-build-row={row.key}>
<div className="live-build-row-head">
<span className={`live-build-tone tone-${toneClass(overall)}`} aria-hidden="true"></span>
<span className="live-build-service">{row.label}</span>
<span className="live-build-time">{row.builtAt ? formatBeijingTime(row.builtAt, { withSeconds: true }) : "构建时间不可用"}</span>
</div>
<div className="live-build-meta">
<span>tag {row.tag ?? "unknown"}</span>
<span>commit {row.commitShort ?? "unknown"}</span>
<span>revision {row.revisionShort ?? "unknown"}</span>
{row.source ? <span> {row.source}</span> : null}
</div>
{row.reason ? <p className="live-build-reason">{row.reason}</p> : null}
</li>
</ul>
</section>
);
}
function buildRowFromLive(service: LiveProbePayload["service"] | undefined, commit: LiveProbePayload["commit"] | undefined, image: LiveProbePayload["image"] | undefined, build: LiveProbePayload["build"] | undefined): ServiceRow {
const label = (service && typeof service === "object" && "id" in service && typeof service.id === "string") ? service.id : "cloud-api";
const serviceId = (service && typeof service === "object" && "id" in service && typeof service.id === "string") ? service.id : null;
const commitShort = (commit && typeof commit === "object" && "id" in commit && typeof commit.id === "string") ? shortToken(commit.id, 12) : null;
const revisionShort = (commit && typeof commit === "object" && "id" in commit && typeof commit.id === "string") ? shortToken(commit.id, 12) : null;
const tag = (image && typeof image === "object" && "tag" in image && typeof image.tag === "string") ? image.tag : null;
const builtAt = (build && typeof build === "object" && "createdAt" in build && typeof build.createdAt === "string") ? build.createdAt : null;
const unavailableReason = (build && typeof build === "object" && "unavailableReason" in build && typeof build.unavailableReason === "string") ? build.unavailableReason : null;
const source = (build && typeof build === "object" && "metadataSource" in build && typeof build.metadataSource === "string") ? build.metadataSource : null;
return {
key: label,
label,
serviceId,
commitShort,
revisionShort,
tag,
builtAt,
status: builtAt ? "ok" : service ? "pending" : "blocked",
reason: unavailableReason ?? null,
source
};
}
export { formatDuration };
@@ -0,0 +1,96 @@
import type { ReactElement } from "react";
import type { LiveProbePayload, LiveSurface } from "../../types/domain";
import { formatBeijingTime, shortToken, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
interface WorkbenchProbeProps {
live: LiveSurface | null;
}
interface ProbeRow {
key: string;
label: string;
method?: string;
path: string;
result: LiveProbePayload | null | undefined;
status: "ok" | "pending" | "blocked" | "source" | "warn";
detail: string;
lastCheckedAt: string | null;
}
interface ProbeResult {
ok: boolean;
status: number;
data: LiveProbePayload | null;
error: string | null;
}
export function WorkbenchProbe({ live }: WorkbenchProbeProps): ReactElement {
const rows: ProbeRow[] = [
rowFromProbe("cloud-api-live", "Cloud API 实况身份", "GET", "/health/live", live?.healthLive ?? null),
rowFromProbe("cloud-api-health", "Cloud API health", "GET", "/health", live?.health ?? null),
rowFromProbe("rest-index", "REST index", "GET", "/v1", live?.restIndex ?? null),
rowFromProbe("adapter", "Codex app-server adapter", "POST", "/v1/rpc/system.health", live?.adapter ?? null)
];
const tone = rows.every((row) => row.status === "ok") ? "ok" : rows.some((row) => row.status === "blocked") ? "blocked" : "pending";
return (
<section className={`probe-card probe-card-${tone} tone-border-${toneClass(tone)}`} data-probe-card>
<header className="probe-card-head">
<p className="eyebrow"></p>
<StateTag tone={toneClass(tone)} data-probe-card-tone>{tone === "ok" ? "健康" : tone === "blocked" ? "阻塞" : "等待验证"}</StateTag>
</header>
<ul className="probe-list" role="list">
{rows.map((row) => (
<li key={row.key} className="probe-row" role="listitem" data-probe-row={row.key}>
<div className="probe-row-head">
<span className={`probe-tone tone-${toneClass(row.status)}`} aria-hidden="true"></span>
<span className="probe-row-label">{row.label}</span>
<span className={`probe-row-status tone-${toneClass(row.status)}`}>{row.status === "ok" ? "通过" : row.status === "blocked" ? "阻塞" : "等待"}</span>
</div>
<div className="probe-row-meta">
<span className="probe-row-method">{row.method}</span>
<span className="probe-row-path mono">{row.path}</span>
<span className="probe-row-time">{row.lastCheckedAt ? formatBeijingTime(row.lastCheckedAt, { withSeconds: true }) : "未观测"}</span>
</div>
<p className="probe-row-detail">{row.detail}</p>
</li>
))}
</ul>
</section>
);
}
function rowFromProbe(key: string, label: string, method: string, path: string, probe: ProbeResult | null): ProbeRow {
const data = probe?.data ?? null;
const ok = Boolean(probe?.ok) && (data?.ready === true || data?.status === "ok" || data?.status === "ready");
const lastCheckedAt = typeof data?.observedAt === "string" ? data.observedAt : null;
const status: ProbeRow["status"] = ok
? "ok"
: probe?.ok === false
? probe.status >= 500 ? "blocked" : "warn"
: probe === null
? "pending"
: "warn";
return {
key,
label,
method,
path,
result: data,
status,
detail: detailFor(probe, data, path),
lastCheckedAt
};
}
function detailFor(probe: ProbeResult | null, data: LiveProbePayload | null, path: string): string {
if (!probe) return `等待 ${path} 实时元数据`;
if (!probe.ok) return probe.error ?? `${path} HTTP ${probe.status}`;
if (!data) return `${path} 响应为空`;
const revision = typeof data.revision === "string" ? data.revision : null;
const serviceId = typeof data.serviceId === "string" ? data.serviceId : null;
const ready = data.ready === true ? "ready=true" : "ready=false";
if (serviceId && revision) return `service=${serviceId}; env=${data.environment ?? "unknown"}; ${ready}; revision=${shortToken(revision, 12)}`;
return `service=${serviceId ?? "unknown"}; ${ready}`;
}
@@ -123,6 +123,29 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.message-runtime-row-wide { grid-column: 1 / -1; }
.message-runtime-summary { color: var(--muted); font-size: 12px; }
.message-session-summary { color: var(--muted); font-size: 12px; }
.probe-card { display: grid; gap: 8px; padding: 10px 12px; border-radius: 8px; background: #fbfcf8; }
.probe-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.probe-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.probe-row { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
.probe-row-head { display: flex; align-items: center; gap: 8px; }
.probe-tone { font-size: 12px; }
.probe-row-label { font-weight: 700; font-size: 13px; }
.probe-row-status { margin-left: auto; font-size: 12px; font-weight: 700; }
.probe-row-meta { display: flex; align-items: baseline; gap: 10px; font-size: 12px; color: var(--muted); flex-wrap: wrap; }
.probe-row-method { font-weight: 800; color: var(--source); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.probe-row-path { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.probe-row-time { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.probe-row-detail { margin: 0; font-size: 12px; overflow-wrap: anywhere; }
.live-build-summary { display: grid; gap: 8px; padding: 10px 12px; border-radius: 8px; background: #fbfcf8; }
.live-build-summary-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.live-build-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.live-build-row { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
.live-build-row-head { display: flex; align-items: center; gap: 8px; }
.live-build-tone { font-size: 12px; }
.live-build-service { font-weight: 700; font-size: 13px; }
.live-build-time { margin-left: auto; color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.live-build-meta { display: flex; flex-wrap: wrap; gap: 10px; font-size: 12px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.live-build-reason { margin: 0; font-size: 12px; color: var(--bad); }
.conversation-list { min-height: 0; display: grid; align-content: start; gap: 12px; overflow: auto; padding-right: 4px; }
.message-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; }
.message-head { display: flex; justify-content: space-between; gap: 10px; }