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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user