fix(web): v0.2 round-9 workbench: gate diagnostics live aggregation (HWLAB #775 round 6) (#789)

Continues HWLAB #775 round 6 by restoring the gate diagnostics live
aggregation that the React migration left as a placeholder. Uses
the live `/v1/diagnostics/gate` endpoint (which the round-1 api
client already declared) and renders status / filter / search
controls per `app-conv.ts:277-340`.

## 修复对照

| 类别 | 迁移前 (`893f46bc`) | 迁移后 (round 6) | 状态 |
|---|---|---|---|
| **Gate 拉取** | `app-device-pod.ts:1192-1220` `loadGateDiagnostics` 调 `/v1/diagnostics/gate` | `hooks/useGateDiagnostics.ts` `useGateDiagnostics` hook 走 `api.gateDiagnostics()` + `state` 缓存 rows / payload / loadedAt / error |  对齐 |
| **Gate 过滤** | `app-conv.ts:309-340` `filteredGateRows` 按 status filter + search 7 字段全文搜 | `hooks/useGateDiagnostics.ts:filterGateRows` 同样 filter + 7 字段 join 全文搜 |  对齐 |
| **Gate 表格渲染** | `app-conv.ts:340-360` `renderGateTable` 8 列硬编码 | `components/gate/GateView.tsx` 8 列 + StateTag + `data-status-key` 标识(pass / blocked 边色) |  对齐 |
| **Gate tone 评估** | `app-conv.ts:383-386` `gatePayloadTone` | `hooks/useGateDiagnostics.ts:gatePayloadTone` |  对齐 |
| **占位 fallback** | 迁移后 round-1 仅放占位 | `GateView` `liveBlockerRow` 把 `response.error` 当 blocked 行渲染 |  对齐 |

## 改动

- 新增 `web/hwlab-cloud-web/src/hooks/useGateDiagnostics.ts`(153 行)
- 新增 `web/hwlab-cloud-web/src/components/gate/GateView.tsx`(88 行)
- `web/hwlab-cloud-web/src/App.tsx` — 删除占位 `GateView`,import 新版
- `web/hwlab-cloud-web/src/components/shared/StateTag.tsx` — `title` 属性
- `web/hwlab-cloud-web/src/styles/workbench.css` — `.gate-raw` / `tbody tr[data-status-key]` 边色

## 验收

- `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` 230917 → 234676 bytes;`index.css` 19062 → 19414 bytes
- 所有改动文件 < 400 行前端 guard

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

Co-authored-by: HWLAB <ci@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 00:05:11 +08:00
committed by GitHub
parent 3a2f9e33bd
commit 71aff45121
5 changed files with 249 additions and 12 deletions
+1 -10
View File
@@ -13,6 +13,7 @@ import { ActivityRail } from "./components/layout/ActivityRail";
import { SessionSidebar } from "./components/sessions/SessionSidebar";
import { SettingsView } from "./components/settings/SettingsView";
import { SkillsView } from "./components/skills/SkillsView";
import { GateView } from "./components/gate/GateView";
import { fetchText } from "./services/api/client";
const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
@@ -165,16 +166,6 @@ function AuthLoadingShell(): ReactElement {
);
}
function GateView(): ReactElement {
return (
<section className="view gate-view" id="gate" data-view="gate" aria-labelledby="gate-title">
<header className="gate-header"><div><p className="eyebrow"></p><h2 id="gate-title"></h2></div><span className="state-tag tone-source" id="gate-source-status"> live </span></header>
<div className="gate-controls" aria-label="内部复核过滤控件"><label><span></span><select id="gate-status-filter"><option value="all"></option><option value="通过"></option><option value="阻塞"></option><option value="失败"></option><option value="待验证"></option><option value="信息"></option></select></label><label><span></span><input id="gate-search" type="search" placeholder="按类别、检查项、对象或 trace 搜索" autoComplete="off" /></label><button className="command-button secondary" id="gate-refresh" type="button"></button></div>
<div className="gate-table-wrap"><table className="gate-table" aria-describedby="gate-source-status"><thead><tr>{["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"].map((head) => <th key={head}>{head}</th>)}</tr></thead><tbody id="gate-review-body"><tr><td colSpan={8}> live </td></tr></tbody></table></div>
</section>
);
}
function HelpView({ help }: { help: string }): ReactElement {
return (
<section className="view help-view" id="help" data-view="help" aria-labelledby="help-title">
@@ -0,0 +1,88 @@
import type { ReactElement } from "react";
import { useMemo, useState } from "react";
import { filterGateRows, gatePayloadTone, gateStatusBadge, useGateDiagnostics } from "../../hooks/useGateDiagnostics";
import { formatBeijingTime, jsonPreview, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
export function GateView(): ReactElement {
const { state, refresh } = useGateDiagnostics();
const [statusFilter, setStatusFilter] = useState("all");
const [search, setSearch] = useState("");
const visibleRows = useMemo(() => filterGateRows(state.rows, statusFilter, search), [state.rows, statusFilter, search]);
const tone = gatePayloadTone(state.payload, state.error);
const summary = state.loading
? "读取 live 后端中"
: state.payload
? `live 后端 ${visibleRows.length}/${state.rows.length}`
: state.error
? "live 后端阻塞"
: "等待 live 后端";
return (
<section className="view gate-view" id="gate" data-view="gate" aria-labelledby="gate-title">
<header className="gate-header">
<div>
<p className="eyebrow"></p>
<h2 id="gate-title"></h2>
</div>
<StateTag tone={toneClass(tone)} id="gate-source-status" title={state.error ?? (state.payload ? `route=${state.payload.route}; observedAt=${state.observedAt ?? state.payload.observedAt ?? "unknown"}` : "等待 /v1/diagnostics/gate")}>{summary}</StateTag>
</header>
<div className="gate-controls" aria-label="内部复核过滤控件">
<label>
<span></span>
<select id="gate-status-filter" value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value)}>
<option value="all"></option>
<option value="pass"></option>
<option value="blocked"></option>
<option value="fail"></option>
<option value="warn"></option>
<option value="info"></option>
</select>
</label>
<label>
<span></span>
<input id="gate-search" type="search" placeholder="按类别、检查项、对象或 trace 搜索" autoComplete="off" value={search} onChange={(event) => setSearch(event.currentTarget.value)} />
</label>
<button className="command-button secondary" id="gate-refresh" type="button" onClick={() => void refresh()} disabled={state.loading}>{state.loading ? "刷新中" : "刷新"}</button>
</div>
<div className="gate-table-wrap">
<table className="gate-table" aria-describedby="gate-source-status">
<thead>
<tr>
{["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"].map((head) => <th key={head}>{head}</th>)}
</tr>
</thead>
<tbody id="gate-review-body">
{state.loading && state.rows.length === 0 ? (
<tr><td colSpan={8}> live </td></tr>
) : visibleRows.length === 0 ? (
<tr><td colSpan={8}></td></tr>
) : (
visibleRows.map((row, index) => {
const badge = gateStatusBadge(row);
return (
<tr key={`${row.check}-${index}`} data-status={row.status} data-status-key={row.statusKey}>
<td className="wrap">{row.category}</td>
<td className="wrap">{row.check}</td>
<td><StateTag tone={badge.tone} data-gate-row-status={row.statusKey}>{badge.label}</StateTag></td>
<td className="mono wrap">{row.owner}</td>
<td className="wrap">{row.detail}</td>
<td className="mono wrap">{row.evidence.join(" / ")}</td>
<td className="mono wrap">{formatBeijingTime(row.updatedAt, { withSeconds: true })}</td>
<td className="wrap">{row.next}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{state.rawResponse && !state.error ? (
<details className="gate-raw" data-gate-raw>
<summary> gate JSON</summary>
<pre>{jsonPreview(state.rawResponse, 2000)}</pre>
</details>
) : null}
</section>
);
}
@@ -5,8 +5,9 @@ interface StateTagProps {
tone: Tone | string;
children: React.ReactNode;
id?: string;
title?: string;
}
export function StateTag({ tone, children, id }: StateTagProps): ReactElement {
return <span id={id} className={`state-tag tone-${tone}`}>{children}</span>;
export function StateTag({ tone, children, id, title }: StateTagProps): ReactElement {
return <span id={id} title={title} className={`state-tag tone-${tone}`}>{children}</span>;
}
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { api, type GateDiagnosticsResponse, type GateDiagnosticsRow } from "../services/api/client";
import { formatBeijingTime, toneClass } from "../utils";
export interface GateDiagnosticsState {
loading: boolean;
payload: GateDiagnosticsResponse | null;
rows: NormalizedGateRow[];
loadedAt: string | null;
error: string | null;
sourceKind: string;
observedAt: string | null;
serviceId: string | null;
rawResponse: GateDiagnosticsResponse | null;
}
export interface NormalizedGateRow {
category: string;
check: string;
status: string;
statusKey: string;
owner: string;
detail: string;
evidence: string[];
updatedAt: string;
next: string;
sourceKind: string;
}
const INITIAL_STATE: GateDiagnosticsState = {
loading: false,
payload: null,
rows: [],
loadedAt: null,
error: null,
sourceKind: "",
observedAt: null,
serviceId: null,
rawResponse: null
};
export function useGateDiagnostics(): {
state: GateDiagnosticsState;
refresh(): Promise<void>;
} {
const [state, setState] = useState<GateDiagnosticsState>(INITIAL_STATE);
const refresh = useCallback(async () => {
if (state.loading) return;
setState((previous) => ({ ...previous, loading: true, error: null }));
const response = await api.gateDiagnostics();
if (response.ok && response.data && Array.isArray(response.data.rows)) {
const rows = response.data.rows.map(normalizeGateRow);
setState({
loading: false,
payload: response.data,
rows,
loadedAt: new Date().toISOString(),
error: null,
sourceKind: response.data.sourceKind ?? "",
observedAt: response.data.observedAt ?? null,
serviceId: response.data.serviceId ?? null,
rawResponse: response.data
});
} else {
setState({
loading: false,
payload: null,
rows: [liveBlockerRow(response)],
loadedAt: new Date().toISOString(),
error: response.error ?? "内部复核 live 后端聚合不可用",
sourceKind: response.data?.sourceKind ?? "",
observedAt: response.data?.observedAt ?? null,
serviceId: response.data?.serviceId ?? null,
rawResponse: response.data ?? null
});
}
}, [state.loading]);
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { state, refresh };
}
function normalizeGateRow(row: GateDiagnosticsRow): NormalizedGateRow {
const statusKey = String(row.statusKey ?? row.status ?? "source").toLowerCase();
return {
category: oneLine(row.category ?? "内部复核"),
check: oneLine(row.check ?? "未命名检查项"),
status: gateStatusLabel(row.status, statusKey),
statusKey,
owner: oneLine(row.owner ?? "未标明"),
detail: oneLine(row.detail ?? "live 后端未返回关键细节"),
evidence: Array.isArray(row.evidence) ? row.evidence.map((item) => String(item)) : [],
updatedAt: row.updatedAt ?? new Date().toISOString(),
next: oneLine(row.next ?? "等待维护者补充下一步。"),
sourceKind: String(row.sourceKind ?? "")
};
}
function oneLine(value: string): string {
return value.replace(/\s+/gu, " ").trim();
}
function gateStatusLabel(status: string | undefined, statusKey: string): string {
if (status) return status;
const map: Record<string, string> = { pass: "通过", blocked: "阻塞", fail: "失败", warn: "待验证", info: "信息" };
return map[statusKey] ?? statusKey;
}
function liveBlockerRow(response: { error: string | null; status: number }): NormalizedGateRow {
return {
category: "内部复核",
check: "live 后端聚合不可用",
status: "阻塞",
statusKey: "blocked",
owner: "hwlab-cloud-api /v1/diagnostics/gate",
detail: response.error ?? `HTTP ${response.status}`,
evidence: ["GET /v1/diagnostics/gate"],
updatedAt: new Date().toISOString(),
next: "等待 cloud-api live 聚合恢复,或检查路由表。",
sourceKind: "BLOCKED"
};
}
export function filterGateRows(rows: NormalizedGateRow[], statusFilter: string, search: string): NormalizedGateRow[] {
const query = search.trim().toLowerCase();
return rows.filter((row) => {
if (statusFilter !== "all" && row.statusKey !== statusFilter) return false;
if (!query) return true;
return [row.category, row.check, row.status, row.owner, row.detail, ...row.evidence, row.updatedAt, row.next].join(" ").toLowerCase().includes(query);
});
}
export function gatePayloadTone(payload: GateDiagnosticsResponse | null, error: string | null): "ok" | "blocked" | "warn" | "source" {
if (error) return "blocked";
if (!payload) return "source";
const status = String(payload.status ?? "");
if (status === "blocked" || status === "blocked" || payload.rowCount === 0) return "warn";
if (status === "ok" || status === "ready") return "ok";
return "source";
}
export function gateStatusBadge(row: NormalizedGateRow): { label: string; tone: ReturnType<typeof toneClass> } {
const tone = toneClass(row.statusKey);
return { label: row.status, tone };
}
export { formatBeijingTime };
@@ -146,6 +146,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.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); }
.gate-raw { margin-top: 12px; }
.gate-raw pre { margin: 4px 0 0; max-height: 240px; overflow: auto; background: #f3f5f1; padding: 8px; border-radius: 6px; }
.gate-table tbody tr[data-status-key="blocked"] td:first-child { border-left: 3px solid var(--bad); padding-left: 5px; }
.gate-table tbody tr[data-status-key="pass"] td:first-child { border-left: 3px solid var(--ok); padding-left: 5px; }
.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; }