From f5be1c03612e84a111a8f1ad4304ad14caf43c8d Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 12:00:16 +0800 Subject: [PATCH] feat: add v02 web performance dashboard --- docs/reference/spec-v02-hwlab-cli.md | 1 + docs/reference/spec-v02-hwlab-cloud-web.md | 1 + .../spec-v02-observability-monitoring.md | 2 + internal/cloud/server.ts | 8 +- internal/cloud/web-performance.test.ts | 29 +++ internal/cloud/web-performance.ts | 156 +++++++++++++++- .../dev-entrypoint/cloud-web-routes.test.mjs | 6 + tools/src/hwlab-cli-lib.ts | 2 + web/hwlab-cloud-web/scripts/check.ts | 6 +- web/hwlab-cloud-web/src/App.tsx | 6 +- .../src/components/layout/ActivityRail.tsx | 5 +- .../performance/PerformanceView.tsx | 176 ++++++++++++++++++ .../src/services/api/client.ts | 2 + web/hwlab-cloud-web/src/styles/workbench.css | 28 +++ web/hwlab-cloud-web/src/types/domain.ts | 41 +++- 15 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 web/hwlab-cloud-web/src/components/performance/PerformanceView.tsx diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 86b0cd95..e2303b9c 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -103,6 +103,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb | `hwlab-cli client workbench restore/status/watch/reset` | `GET/PATCH /v1/workbench/workspace*` | 恢复、观察或重置账号级共享 workspace,支持 Web/CLI 和多 profile 共享同一账号状态。 | | `hwlab-cli client rpc METHOD [--full]` | `POST /json-rpc` | 像 Web `callRpc` 一样自动生成 `id`、`traceId` 和 `meta`,覆盖 `system.health`、`cloud.adapter.describe` 等 JSON-RPC 非视觉能力。 | | `hwlab-cli client request METHOD /path [--full]` | Cloud Web 同源相对路径 | 覆盖 `/v1/access/status`、`/v1/setup/status`、`/v1/diagnostics/gate`、`/v1/m3/status`、`/v1/m3/io` 等低频或新增 WEB API;`/json-rpc` 优先使用 `client rpc`。 | +| `hwlab-cli client request GET /v1/web-performance/summary [--full]` | `GET /v1/web-performance/summary` | 性能监控页同路径非视觉验收入口;输出 WebUI RUM 样本、慢 API route p95、Web Vitals、long task 和问题队列摘要,不访问 Prometheus 公网地址,不读取 `/metrics` 原始文本。 | `client` 之外的历史命令不作为 v0.2 验收入口。保留旧命令时只能返回废弃说明或转向 `client`,不得把 fixture 或 dry-run 结果当作 WEB 功能通过证据。 diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 19f5f784..c22a7573 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -100,6 +100,7 @@ | `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口;steer 必须走同一个 `19666` Web path,由 cloud-api/AgentRun 判断目标 turn 是否可接收。 | | `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 | | `POST /v1/web-performance` | 浏览器 RUM 上报入口;只允许低基数性能事件和数值,Cloud API 聚合后进入 Prometheus,详见 [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md)。 | +| `GET /v1/web-performance/summary` | 性能监控顶级页读取的同源摘要接口;返回低基数 WebUI 体感性能 JSON,包含样本数、route p95、Web Vitals、long task 和错误/超时问题队列,不返回 Prometheus 原始文本或高基数 trace/session/conversation/thread/user 标识。 | | `POST /v1/m3/io`、`POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 | ## 测试规格 diff --git a/docs/reference/spec-v02-observability-monitoring.md b/docs/reference/spec-v02-observability-monitoring.md index 2ce14399..26070525 100644 --- a/docs/reference/spec-v02-observability-monitoring.md +++ b/docs/reference/spec-v02-observability-monitoring.md @@ -32,6 +32,8 @@ sidecar 脚本通过 ConfigMap 挂载时,Deployment template 必须包含脚 Cloud Web 用户感知性能必须进入同一套 Prometheus 查询面。浏览器侧 RUM 只上报低基数 route template、metric、method、status class、outcome 和数值,不上报 trace/session/conversation/thread/run/job/user ID 或正文。`hwlab-cloud-api` 负责接收 `/v1/web-performance`、做进程内聚合,并仅允许 `hwlab-cloud-api` pod 内 loopback 访问 `/v1/web-performance/metrics`;`hwlab-cloud-api` 的 metrics sidecar 再通过额外 loopback target 把 WebUI Prometheus 文本附加到 9100 `/metrics`。公网 `19666/19667` 仍不得暴露 Prometheus 原始文本。 +Cloud Web 顶级性能页通过同源 `GET /v1/web-performance/summary` 读取低基数 JSON 摘要,用于展示用户可感知的 WebUI 样本数、慢 API route p95、Web Vitals、long task 和错误/超时问题队列。该接口不得返回 Prometheus 原始文本、Secret、prompt/assistant 正文或 trace/session/conversation/thread/user 等高基数标识;CLI 同路径验收使用 `hwlab-cli client request GET /v1/web-performance/summary`,不直连 Prometheus 公网地址。 + ## API 接口说明 ### `/metrics` diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 20c3c090..ec96efab 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -48,7 +48,8 @@ import { buildLiveBuildsPayload } from "./server-rest-payloads.ts"; import { createWebPerformanceStore, handleWebPerformanceIngestHttp, - handleWebPerformanceMetricsHttp + handleWebPerformanceMetricsHttp, + handleWebPerformanceSummaryHttp } from "./web-performance.ts"; import { createCodeAgentChatResultStore, @@ -304,6 +305,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/web-performance/summary" && request.method === "GET") { + handleWebPerformanceSummaryHttp(request, response, { store: options.webPerformanceStore }); + return; + } + if (request.method === "GET" && url.pathname === "/v1") { const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe); const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options); diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index 04350679..07d261f1 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -24,6 +24,29 @@ test("web performance store aggregates browser-perceived durations as Prometheus assert.doesNotMatch(text, /trc_secret|sessionId|conversationId|threadId|prompt|api key/iu); }); +test("web performance summary exposes user-perceived route latency without high-cardinality data", () => { + const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v02", HWLAB_GITOPS_TARGET: "v02" } }); + store.record({ + schemaVersion: "hwlab-web-performance-v1", + page: "/workspace", + events: [ + { kind: "api", metric: "api_request", route: "/v1/agent/chat/result/trc_secret", method: "GET", status: 200, statusClass: "2xx", outcome: "ok", valueMs: 420 }, + { kind: "api", metric: "api_request", route: "/v1/agent/chat/result/trc_secret", method: "GET", status: 0, statusClass: "network", outcome: "timeout", valueMs: 2600 }, + { kind: "long_task", metric: "long_task", route: "/workspace", valueMs: 260 }, + { kind: "web_vital", metric: "lcp", route: "/workspace", valueMs: 3100 } + ] + }); + + const summary = store.summary(); + const json = JSON.stringify(summary); + assert.equal(summary.summary.sampleCount, 4); + assert.equal(summary.namespace, "hwlab-v02"); + assert.ok(summary.apiRoutes.some((row) => row.route === "/v1/agent/chat/result/:id" && row.metric === "api_request" && row.p95 >= 2.5)); + assert.ok(summary.problems.some((row) => row.problem === "timeout" && row.tone === "blocked")); + assert.ok(summary.longTasks.some((row) => row.metric === "long_task" && row.problem === "slow-long_task" && row.tone !== "ok")); + assert.doesNotMatch(json, /trc_secret|sessionId|conversationId|threadId|prompt|api key/iu); +}); + test("web performance route templates keep metrics low-cardinality", () => { assert.equal(webPerformanceRouteTemplate("/v1/workbench/workspace/cnv_abcdef/select-conversation?traceId=trc_secret"), "/v1/workbench/workspace/:id/select-conversation"); assert.equal(webPerformanceRouteTemplate("#/skills"), "/skills"); @@ -53,6 +76,12 @@ test("cloud api ingests WebUI performance samples and exposes metrics only on lo const publicHost = await fetch(`${baseUrl}/v1/web-performance/metrics`, { headers: { host: "74.48.78.17:19667" } }); assert.equal(publicHost.status, 404); + + const summary = await fetch(`${baseUrl}/v1/web-performance/summary`); + const body = await summary.json(); + assert.equal(summary.status, 200); + assert.equal(body.route, "/v1/web-performance/summary"); + assert.equal(body.summary.sampleCount, 1); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index 5b7ca07e..9080b35c 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -49,6 +49,22 @@ interface CounterSeries { value: number; } +interface PerformanceSummaryRow { + kind: string; + metric: string; + route: string; + method: string; + statusClass: string; + outcome: string; + count: number; + average: number; + p50: number; + p95: number; + unit: "seconds" | "score"; + tone: "ok" | "warn" | "blocked" | "source"; + problem: string; +} + export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) { const env = options.env ?? process.env; const maxSeries = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 240); @@ -101,7 +117,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = metric, route, method: normalizeMethod(input.method), - status_class: normalizeStatusClass(input.statusClass ?? statusClass(input.status)), + status_class: normalizeStatusClass(input.statusClass ?? (input.status === undefined || input.status === null ? "unknown" : statusClass(input.status))), outcome: sanitizeLabelValue(input.outcome ?? "ok", "ok") }; return { metric, value, labels }; @@ -123,6 +139,51 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = return lines.join("\n"); } + function summary() { + const durationRows = histogramSummaryRows(durationSeries, DURATION_BUCKETS_SECONDS, "seconds"); + const clsRows = histogramSummaryRows(clsSeries, CLS_BUCKETS, "score"); + const rows = [...durationRows, ...clsRows]; + const apiRoutes = durationRows + .filter((row) => row.metric === "api_request") + .sort(sortByProblemThenP95) + .slice(0, 12); + const webVitals = rows + .filter((row) => row.kind === "web_vital" || row.metric.startsWith("navigation_")) + .sort(sortByProblemThenP95) + .slice(0, 12); + const longTasks = durationRows + .filter((row) => row.metric === "long_task") + .sort(sortByProblemThenP95) + .slice(0, 12); + const problems = rows + .filter((row) => row.tone === "blocked" || row.tone === "warn" || row.outcome !== "ok") + .sort(sortByProblemThenP95) + .slice(0, 16); + const sampleCount = [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0); + return { + serviceId: "hwlab-cloud-api", + route: "/v1/web-performance/summary", + source: "cloud-api-rum-store", + observedAt: new Date().toISOString(), + namespace: baseLabels.namespace, + gitopsTarget: baseLabels.gitops_target, + summary: { + sampleCount, + sampleSeries: sampleSeries.size, + durationSeries: durationSeries.size, + clsSeries: clsSeries.size, + routeCount: new Set(rows.map((row) => row.route)).size, + problemCount: problems.length, + status: sampleCount === 0 ? "waiting" : sampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok" + }, + apiRoutes, + webVitals, + longTasks, + problems, + rows: rows.sort(sortByProblemThenP95).slice(0, 40) + }; + } + function snapshot() { return { sampleSeries: sampleSeries.size, @@ -132,7 +193,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = }; } - return { record, metricsText, snapshot }; + return { record, metricsText, snapshot, summary }; } export async function handleWebPerformanceIngestHttp(request, response, options = {}) { @@ -165,6 +226,10 @@ export function handleWebPerformanceMetricsHttp(request, response, options = {}) response.end(payload); } +export function handleWebPerformanceSummaryHttp(_request, response, options = {}) { + sendJson(response, 200, options.store.summary()); +} + export function webPerformanceRouteTemplate(input: unknown): string { const raw = String(input ?? "").trim() || "/"; const normalizedRaw = raw.startsWith("#") ? raw.slice(1) || "/" : raw; @@ -237,6 +302,93 @@ function renderHistogram(metricName: string, series: Map, buckets: number[], unit: "seconds" | "score"): PerformanceSummaryRow[] { + return [...series.values()].map((entry) => { + const labels = entry.labels; + const count = entry.count; + const average = count > 0 ? entry.sum / count : 0; + const p50 = histogramQuantile(entry, buckets, 0.5); + const p95 = histogramQuantile(entry, buckets, 0.95); + const tone = performanceTone(labels, p95, count, unit); + return { + kind: labels.kind, + metric: labels.metric, + route: labels.route, + method: labels.method, + statusClass: labels.status_class, + outcome: labels.outcome, + count, + average: roundMetric(average), + p50: roundMetric(p50), + p95: roundMetric(p95), + unit, + tone, + problem: performanceProblem(labels, p95, count, unit, tone) + }; + }); +} + +function histogramQuantile(entry: HistogramSeries, buckets: number[], quantile: number) { + if (entry.count <= 0) return 0; + const rank = Math.max(1, Math.ceil(entry.count * quantile)); + for (let index = 0; index < entry.buckets.length; index += 1) { + if (entry.buckets[index] >= rank) return buckets[index]; + } + return buckets[buckets.length - 1] ?? 0; +} + +function performanceTone(labels: Record, p95: number, count: number, unit: "seconds" | "score") { + if (labels.outcome !== "ok" || labels.status_class === "network" || labels.status_class === "5xx") return "blocked"; + if (labels.status_class === "4xx") return "warn"; + if (count <= 0) return "source"; + const metric = labels.metric; + if (unit === "score") { + if (p95 >= 0.25) return "blocked"; + if (p95 >= 0.1) return "warn"; + return "ok"; + } + if (metric === "lcp") return thresholdTone(p95, 2.5, 4); + if (metric === "inp" || metric === "fid") return thresholdTone(p95, 0.2, 0.5); + if (metric === "api_request") return thresholdTone(p95, 1, 2.5); + if (metric === "long_task") return thresholdTone(p95, 0.2, 0.5); + if (metric === "navigation_dom_content_loaded") return thresholdTone(p95, 2, 4); + if (metric === "navigation_load") return thresholdTone(p95, 3, 6); + if (metric === "navigation_ttfb") return thresholdTone(p95, 0.8, 1.8); + return p95 > 1 ? "warn" : "ok"; +} + +function thresholdTone(value: number, warn: number, blocked: number) { + if (value >= blocked) return "blocked"; + if (value >= warn) return "warn"; + return "ok"; +} + +function performanceProblem(labels: Record, p95: number, count: number, unit: "seconds" | "score", tone: string) { + if (count <= 0) return "no-sample"; + if (labels.outcome !== "ok") return labels.outcome; + if (labels.status_class === "network") return "network"; + if (labels.status_class === "5xx") return "server-error"; + if (labels.status_class === "4xx") return "client-error"; + if (tone === "blocked" || tone === "warn") return unit === "score" ? "layout-shift" : `slow-${labels.metric}`; + return "ok"; +} + +function sortByProblemThenP95(left: PerformanceSummaryRow, right: PerformanceSummaryRow) { + return toneWeight(right.tone) - toneWeight(left.tone) || right.p95 - left.p95 || right.count - left.count; +} + +function toneWeight(tone: string) { + if (tone === "blocked") return 4; + if (tone === "warn") return 3; + if (tone === "ok") return 2; + return 1; +} + +function roundMetric(value: number) { + if (!Number.isFinite(value)) return 0; + return Math.round(value * 10000) / 10000; +} + function renderLabels(labels: Record) { return Object.entries(labels) .map(([key, value]) => `${key}="${escapeLabelValue(value)}"`) diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index f07a7f6e..c4fbabdb 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -46,6 +46,12 @@ test("cloud web proxies public WebUI performance telemetry to cloud-api", () => publicRoute: true, routeKey: "POST /v1/web-performance" }); + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/web-performance/summary"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "GET /v1/web-performance/summary" + }); }); test("cloud web proxies authenticated API key management write routes", () => { diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 10d81052..7fd4fa39 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -141,6 +141,7 @@ function help() { "hwlab-cli client workbench watch --profile NAME --after-revision REVISION", "hwlab-cli client workbench reset --profile NAME --confirm", "hwlab-cli client request GET /v1/access/status [--full]", + "hwlab-cli client request GET /v1/web-performance/summary [--full]", "hwlab-cli client rpc system.health [--full]", "hwlab-cli client agent session create --provider-profile minimax-m3", "hwlab-cli client agent session select SESSION_ID", @@ -1932,6 +1933,7 @@ function requestHelp() { imagePublished: false, commands: [ "GET /path [--full]", + "GET /v1/web-performance/summary [--full]", "POST /path --body-json '{...}' [--full]", "METHOD /path --body-file FILE" ] diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index c701cecf..cfe2ddb8 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -18,6 +18,7 @@ const requiredFiles = Object.freeze([ "src/components/conversation/ConversationPanel.tsx", "src/components/command-bar/CommandBar.tsx", "src/components/device-pod/DevicePodSidebar.tsx", + "src/components/performance/PerformanceView.tsx", "src/components/settings/SettingsView.tsx", "src/components/skills/SkillsView.tsx", "src/hooks/useAuth.ts", @@ -53,6 +54,7 @@ assertIncludes(appSource, "useWorkbenchStore", "state hook must own workbench st assertIncludes(appSource, "fetchJson", "API client must own fetch calls"); assertIncludes(appSource, "DevicePodSidebar", "Device Pod sidebar component must exist"); assertIncludes(appSource, "CommandBar", "command bar component must exist"); +assertIncludes(appSource, "PerformanceView", "performance view component must exist"); assertIncludes(appSource, "SkillsView", "skills component must exist"); assertIncludes(appSource, "SettingsView", "settings component must exist"); assertIncludes(appTsx, "useWorkbenchStore(auth.authState === \"authenticated\")", "Workbench data requests must wait until auth is authenticated"); @@ -66,10 +68,12 @@ assertIncludes(appSource, "id=\"conversation-list\"", "React runtime must render assertIncludes(appSource, "id=\"device-pod-sidebar\"", "React runtime must render Device Pod sidebar selector"); assertIncludes(appSource, "id=\"device-event-scroll\"", "React runtime must render Device Pod event stream selector"); assertIncludes(appSource, "id=\"logout-button\"", "React runtime must render logout selector"); +assertIncludes(appSource, "route === \"performance\"", "React runtime must route top-level performance panel"); +assertIncludes(readRepo("internal/cloud/server.ts"), "/v1/web-performance/summary", "cloud-api must expose WebUI performance summary API"); assert.doesNotMatch(appSource, /document\.getElementById\((?!["']root["'])|querySelector\(["']#(?:command-input|conversation-list|device-pod-sidebar)/u, "major UI must not be driven by global DOM element lookups"); assert.doesNotMatch(appSource, /\bconst\s+el\s*=/u, "global el map must not return"); -for (const directory of ["components/layout", "components/auth", "components/sessions", "components/conversation", "components/command-bar", "components/device-pod", "components/settings", "components/skills", "hooks", "services/api", "state", "types", "styles"]) { +for (const directory of ["components/layout", "components/auth", "components/sessions", "components/conversation", "components/command-bar", "components/device-pod", "components/performance", "components/settings", "components/skills", "hooks", "services/api", "state", "types", "styles"]) { assert.ok(fs.existsSync(path.join(rootDir, "src", directory)), `missing React module boundary src/${directory}`); } assert.match(css, /@media \(max-width: 720px\)/u, "mobile layout contract must be explicit"); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 45e38673..341ef003 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -17,6 +17,7 @@ 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 { PerformanceView } from "./components/performance/PerformanceView"; import { WorkbenchBuildSummary } from "./components/workbench/WorkbenchBuildSummary"; import { WorkbenchProbe } from "./components/workbench/WorkbenchProbe"; import { fetchText } from "./services/api/client"; @@ -34,6 +35,7 @@ const RIGHT_SIDEBAR_MAX_WIDTH = 680; const routeLabels: Record = { workspace: "工作台", + performance: "性能监控", skills: "Skills", access: "授权管理", gate: "内部复核", @@ -174,6 +176,7 @@ export function App(): ReactElement { ) : null}
{route === "workspace" ? copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null} + {route === "performance" ? : null} {route === "skills" ? : null} {route === "access" ? : null} {route === "gate" ? : null} @@ -274,9 +277,10 @@ function HelpView({ help }: { help: string }): ReactElement { function routeFromLocation(): RouteId { const hash = window.location.hash.replace(/^#\/?/u, ""); - if (hash === "skills" || hash === "access" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash; + if (hash === "performance" || hash === "skills" || hash === "access" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash; const path = window.location.pathname.replace(/\/+$/u, "") || "/"; if (path === "/help") return "help"; + if (path === "/performance" || path === "/ops/performance") return "performance"; if (path === "/skills") return "skills"; if (path === "/access" || path === "/admin/access") return "access"; if (path === "/gate" || path === "/diagnostics/gate") return "gate"; diff --git a/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx b/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx index 463f9051..24133f54 100644 --- a/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx +++ b/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx @@ -8,18 +8,19 @@ interface ActivityRailProps { const routes: Array<{ id: RouteId; label: string; title: string }> = [ { id: "workspace", label: "工作台", title: "工作台" }, + { id: "performance", label: "性能", title: "性能监控" }, { id: "skills", label: "Skills", title: "Skills" }, { id: "access", label: "授权", title: "授权管理" }, { id: "gate", label: "内部复核", title: "内部复核" }, { id: "help", label: "使用说明", title: "使用说明" }, { id: "settings", label: "设置", title: "设置" } ]; -const settingsRoute = routes[5]; +const settingsRoute = routes.find((item) => item.id === "settings"); export function ActivityRail({ route, onRoute }: ActivityRailProps): ReactElement { return ( diff --git a/web/hwlab-cloud-web/src/components/performance/PerformanceView.tsx b/web/hwlab-cloud-web/src/components/performance/PerformanceView.tsx new file mode 100644 index 00000000..86015146 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/performance/PerformanceView.tsx @@ -0,0 +1,176 @@ +import type { ReactElement } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import type { WebPerformanceSummaryResponse, WebPerformanceSummaryRow } from "../../types/domain"; +import { api } from "../../services/api/client"; +import { asArray, formatBeijingTime, toneClass } from "../../utils"; +import { StateTag } from "../shared/StateTag"; + +type PerformanceState = { + loading: boolean; + payload: WebPerformanceSummaryResponse | null; + error: string | null; + observedAt: string | null; +}; + +const SLOW_ROW_LIMIT = 8; + +export function PerformanceView(): ReactElement { + const [state, setState] = useState({ loading: true, payload: null, error: null, observedAt: null }); + + async function refresh(): Promise { + setState((current) => ({ ...current, loading: true, error: null })); + const response = await api.webPerformanceSummary(); + setState({ + loading: false, + payload: response.ok ? response.data : null, + error: response.ok ? null : response.error ?? "性能摘要不可用", + observedAt: response.ok ? response.data?.observedAt ?? new Date().toISOString() : null + }); + } + + useEffect(() => { + void refresh(); + const timer = window.setInterval(() => void refresh(), 15000); + return () => window.clearInterval(timer); + }, []); + + const payload = state.payload; + const summary = payload?.summary ?? {}; + const sampleCount = numeric(summary.sampleCount); + const problemCount = numeric(summary.problemCount); + const status = state.error ? "blocked" : sampleCount === 0 ? "pending" : problemCount > 0 ? "warn" : "ok"; + const apiRows = useMemo(() => sortRows(payload?.apiRoutes).slice(0, SLOW_ROW_LIMIT), [payload]); + const vitalRows = useMemo(() => sortRows(payload?.webVitals).slice(0, SLOW_ROW_LIMIT), [payload]); + const longTaskRows = useMemo(() => sortRows(payload?.longTasks).slice(0, SLOW_ROW_LIMIT), [payload]); + const problemRows = useMemo(() => sortRows(payload?.problems).slice(0, SLOW_ROW_LIMIT), [payload]); + + return ( +
+
+
+

User-perceived WebUI

+

性能监控

+
+
+ {performanceStatusLabel(state, sampleCount, problemCount)} + +
+
+ + {state.error ?

{state.error}

: null} + +
+ + 0 ? "需要关注的慢路径/错误" : "暂无明显问题"} tone={problemCount > 0 ? "warn" : "ok"} /> + + +
+ +
+ source {payload?.source ?? "cloud-api-rum-store"} + namespace {payload?.namespace ?? "hwlab-v02"} + target {payload?.gitopsTarget ?? "v02"} + observed {formatBeijingTime(state.observedAt, { withSeconds: true })} +
+ +
+ + + + +
+
+ ); +} + +function MetricTile({ label, value, detail, tone }: { label: string; value: string; detail: string; tone?: string }): ReactElement { + return ( +
+ {label} + {value} +

{detail}

+
+ ); +} + +function PerformanceTable({ title, eyebrow, empty, rows, showProblem = false }: { title: string; eyebrow: string; empty: string; rows: WebPerformanceSummaryRow[]; showProblem?: boolean }): ReactElement { + return ( +
+
+

{eyebrow}

{title}

+ 0 ? toneClass(rows[0]?.tone) : "source"}>{rows.length > 0 ? `${rows.length} rows` : "waiting"} +
+ {rows.length === 0 ?

{empty}

: ( +
+ + + + + + + + + {showProblem ? : null} + + + + {rows.map((row, index) => )} + +
对象指标p95样本结果问题
+
+ )} +
+ ); +} + +function PerformanceRow({ row, showProblem }: { row: WebPerformanceSummaryRow; showProblem: boolean }): ReactElement { + return ( + + {row.route ?? "--"} + {row.metric ?? row.kind ?? "metric"}{row.method && row.method !== "NONE" ? {row.method} : null} + {formatMetric(row.p95, row.unit)} + {numeric(row.count)} + {row.outcome ?? row.statusClass ?? "ok"} + {showProblem ? {row.problem ?? "ok"} : null} + + ); +} + +function sortRows(rows: unknown): WebPerformanceSummaryRow[] { + return asArray(rows).sort((left, right) => toneWeight(right.tone) - toneWeight(left.tone) || numeric(right.p95) - numeric(left.p95) || numeric(right.count) - numeric(left.count)); +} + +function performanceStatusLabel(state: PerformanceState, sampleCount: number, problemCount: number): string { + if (state.loading && !state.payload) return "读取中"; + if (state.error) return "性能摘要阻塞"; + if (sampleCount === 0) return "等待样本"; + if (sampleCount < 10) return `低样本 ${sampleCount}`; + if (problemCount > 0) return `${problemCount} 个问题`; + return "观测正常"; +} + +function maxP95(rows: WebPerformanceSummaryRow[]): number { + return rows.reduce((max, row) => Math.max(max, numeric(row.p95)), 0); +} + +function formatMetric(value: unknown, unit: unknown): string { + const number = numeric(value); + if (number <= 0) return "--"; + if (unit === "score") return number.toFixed(number < 0.1 ? 3 : 2); + if (number < 1) return `${Math.round(number * 1000)}ms`; + return `${number.toFixed(number < 10 ? 2 : 1)}s`; +} + +function numeric(value: unknown): number { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : 0; +} + +function toneWeight(value: unknown): number { + const tone = toneClass(value); + if (tone === "blocked" || tone === "error") return 4; + if (tone === "warn" || tone === "pending") return 3; + if (tone === "ok") return 2; + return 1; +} diff --git a/web/hwlab-cloud-web/src/services/api/client.ts b/web/hwlab-cloud-web/src/services/api/client.ts index b9b0b5b1..4f1864c9 100644 --- a/web/hwlab-cloud-web/src/services/api/client.ts +++ b/web/hwlab-cloud-web/src/services/api/client.ts @@ -16,6 +16,7 @@ import type { DevicePodStatusResponse, LiveBuildsPayload, LiveProbePayload, + WebPerformanceSummaryResponse, SkillsResponse, SkillFileResponse, SkillTreeResponse, @@ -235,6 +236,7 @@ export const api = { getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }), cancelAgentMessage: (payload: Record): Promise> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }), gateDiagnostics: (): Promise> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }), + webPerformanceSummary: (): Promise> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }), skills: (): Promise> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }), uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }), skillTree: (skillId: string): Promise> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }), diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 5410c389..904ddc9e 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -360,6 +360,31 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .access-switch input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } .access-switch input:disabled + span { opacity: 0.55; } .access-switch.busy span { background: #fff3d8; border-color: #e4bd68; } +.performance-view { display: grid; gap: 12px; align-content: start; background: #eef3ef; } +.performance-header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.performance-header h2, .performance-panel h3 { margin: 0; } +.performance-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } +.performance-error { margin: 0; border: 1px solid #e7a1a1; border-radius: 6px; background: #fff3f3; color: var(--bad); padding: 8px 10px; font-size: 13px; font-weight: 700; } +.performance-kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 10px; } +.performance-tile { min-width: 0; min-height: 104px; display: grid; align-content: start; gap: 6px; border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; box-shadow: 0 1px 0 rgba(0,0,0,0.03); } +.performance-tile span { color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; } +.performance-tile strong { font-size: 26px; line-height: 1; } +.performance-tile p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; } +.performance-source-line { min-width: 0; display: flex; align-items: center; gap: 8px 14px; flex-wrap: wrap; color: var(--muted); font-size: 12px; } +.performance-source-line strong { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.performance-grid { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; } +.performance-panel { min-width: 0; display: grid; gap: 10px; padding: 12px; } +.performance-panel-head { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.performance-empty { margin: 0; border: 1px dashed var(--line); border-radius: 6px; background: #fbfcf8; color: var(--muted); padding: 14px; font-size: 13px; text-align: center; } +.performance-table-wrap { min-width: 0; overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; } +.performance-table { width: 100%; min-width: 680px; border-collapse: collapse; } +.performance-table th, .performance-table td { border-bottom: 1px solid var(--line); padding: 8px; text-align: left; vertical-align: top; font-size: 13px; } +.performance-table th { color: var(--muted); font-size: 12px; font-weight: 900; text-transform: uppercase; } +.performance-table tbody tr[data-performance-tone="blocked"] td:first-child { border-left: 3px solid var(--bad); padding-left: 5px; } +.performance-table tbody tr[data-performance-tone="warn"] td:first-child { border-left: 3px solid var(--warn); padding-left: 5px; } +.performance-table tbody tr[data-performance-tone="ok"] td:first-child { border-left: 3px solid var(--ok); padding-left: 5px; } +.performance-metric-name { display: inline-block; font-weight: 800; } +.performance-method { display: inline-flex; margin-left: 6px; border: 1px solid var(--line); border-radius: 999px; padding: 1px 6px; color: var(--source); font-size: 11px; font-weight: 900; } .gate-view { display: grid; gap: 12px; } .gate-controls { display: flex; flex-wrap: wrap; gap: 8px; } .gate-table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; } @@ -409,6 +434,9 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .device-pod-meta { display: none; } .settings-group { grid-template-columns: minmax(0, 1fr); } .skills-layout { grid-template-columns: minmax(0, 1fr); } + .performance-header { align-items: flex-start; flex-direction: column; } + .performance-kpi-grid, .performance-grid { grid-template-columns: minmax(0, 1fr); } + .performance-table { min-width: 560px; } .access-panel { min-height: auto; overflow: visible; } .access-layout { grid-template-columns: minmax(0, 1fr); overflow: visible; } .access-user-list { max-height: none; } diff --git a/web/hwlab-cloud-web/src/types/domain.ts b/web/hwlab-cloud-web/src/types/domain.ts index fea35f51..c98f54fe 100644 --- a/web/hwlab-cloud-web/src/types/domain.ts +++ b/web/hwlab-cloud-web/src/types/domain.ts @@ -2,7 +2,7 @@ import type { LiveBuildsPayload } from "./live-builds"; export type { LiveBuildBuild, LiveBuildCommit, LiveBuildImage, LiveBuildRecord, LiveBuildsPayload } from "./live-builds"; export type Tone = "ok" | "source" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run"; -export type RouteId = "workspace" | "skills" | "access" | "gate" | "help" | "settings"; +export type RouteId = "workspace" | "performance" | "skills" | "access" | "gate" | "help" | "settings"; export type AuthState = "checking" | "login" | "authenticated"; export type ProviderProfile = "deepseek" | "codex-api" | "minimax-m3"; @@ -53,6 +53,45 @@ export interface LiveSurface { loadedAt: string; } +export interface WebPerformanceSummaryRow { + kind?: string; + metric?: string; + route?: string; + method?: string; + statusClass?: string; + outcome?: string; + count?: number; + average?: number; + p50?: number; + p95?: number; + unit?: "seconds" | "score"; + tone?: Tone | string; + problem?: string; +} + +export interface WebPerformanceSummaryResponse { + serviceId?: string; + route?: string; + source?: string; + observedAt?: string; + namespace?: string; + gitopsTarget?: string; + summary?: { + sampleCount?: number; + sampleSeries?: number; + durationSeries?: number; + clsSeries?: number; + routeCount?: number; + problemCount?: number; + status?: string; + }; + apiRoutes?: WebPerformanceSummaryRow[]; + webVitals?: WebPerformanceSummaryRow[]; + longTasks?: WebPerformanceSummaryRow[]; + problems?: WebPerformanceSummaryRow[]; + rows?: WebPerformanceSummaryRow[]; +} + export interface CodeAgentAvailability { status?: string; ready?: boolean;