From 71aff45121df88227690867582ebfd1914f4d786 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:05:11 +0800 Subject: [PATCH] fix(web): v0.2 round-9 workbench: gate diagnostics live aggregation (HWLAB #775 round 6) (#789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/hwlab-cloud-web/src/App.tsx | 11 +- .../src/components/gate/GateView.tsx | 88 ++++++++++ .../src/components/shared/StateTag.tsx | 5 +- .../src/hooks/useGateDiagnostics.ts | 153 ++++++++++++++++++ web/hwlab-cloud-web/src/styles/workbench.css | 4 + 5 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 web/hwlab-cloud-web/src/components/gate/GateView.tsx create mode 100644 web/hwlab-cloud-web/src/hooks/useGateDiagnostics.ts diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 3a7583a2..35f5a0d4 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -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 ( -
-

内部二级入口

内部复核

等待 live 后端
-
-
{["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"].map((head) => )}
{head}
正在读取 live 后端聚合。
-
- ); -} - function HelpView({ help }: { help: string }): ReactElement { return (
diff --git a/web/hwlab-cloud-web/src/components/gate/GateView.tsx b/web/hwlab-cloud-web/src/components/gate/GateView.tsx new file mode 100644 index 00000000..3918c005 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/gate/GateView.tsx @@ -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 ( +
+
+
+

内部二级入口

+

内部复核

+
+ {summary} +
+
+ + + +
+
+ + + + {["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"].map((head) => )} + + + + {state.loading && state.rows.length === 0 ? ( + + ) : visibleRows.length === 0 ? ( + + ) : ( + visibleRows.map((row, index) => { + const badge = gateStatusBadge(row); + return ( + + + + + + + + + + + ); + }) + )} + +
{head}
正在读取 live 后端聚合。
没有匹配当前过滤条件的复核行。
{row.category}{row.check}{badge.label}{row.owner}{row.detail}{row.evidence.join(" / ")}{formatBeijingTime(row.updatedAt, { withSeconds: true })}{row.next}
+
+ {state.rawResponse && !state.error ? ( +
+ 展开 gate 原始 JSON +
{jsonPreview(state.rawResponse, 2000)}
+
+ ) : null} +
+ ); +} diff --git a/web/hwlab-cloud-web/src/components/shared/StateTag.tsx b/web/hwlab-cloud-web/src/components/shared/StateTag.tsx index bf8b3fd9..9e2f6317 100644 --- a/web/hwlab-cloud-web/src/components/shared/StateTag.tsx +++ b/web/hwlab-cloud-web/src/components/shared/StateTag.tsx @@ -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 {children}; +export function StateTag({ tone, children, id, title }: StateTagProps): ReactElement { + return {children}; } diff --git a/web/hwlab-cloud-web/src/hooks/useGateDiagnostics.ts b/web/hwlab-cloud-web/src/hooks/useGateDiagnostics.ts new file mode 100644 index 00000000..01790274 --- /dev/null +++ b/web/hwlab-cloud-web/src/hooks/useGateDiagnostics.ts @@ -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; +} { + const [state, setState] = useState(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 = { 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 } { + const tone = toneClass(row.statusKey); + return { label: row.status, tone }; +} + +export { formatBeijingTime }; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index f100fde7..cbfc3524 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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; }