diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index 2f38dc6a..8d467af3 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -44,6 +44,10 @@ test("web performance summary exposes user-perceived route latency without high- const json = JSON.stringify(summary); assert.equal(summary.summary.sampleCount, 4); assert.equal(summary.namespace, "hwlab-v02"); + assert.equal(summary.dashboard.schemaVersion, "hwlab-web-performance-dashboard-v1"); + assert.ok(summary.dashboard.cards.some((card) => card.id === "samples" && card.value === 4)); + assert.ok(summary.dashboard.trends.some((trend) => trend.id === "api-timing" && trend.points.length > 0)); + assert.ok(summary.dashboard.topN.some((group) => group.id === "top-api" && group.rows.length > 0)); 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")); @@ -131,6 +135,9 @@ test("web performance summary exposes Workbench p75 and low-sample diagnostics w assert.deepEqual(result, { accepted: 4, dropped: 0, received: 4 }); assert.equal(summary.summary.sampleCount, 4); assert.equal(summary.summary.lowSampleThreshold, 5); + assert.equal(summary.dashboard.freshness.namespace, "hwlab-v03"); + assert.ok(summary.dashboard.trends.some((trend) => trend.id === "workbench-experience" && trend.points.some((point) => point.label === "提交到首个可见结果"))); + assert.ok(summary.dashboard.distributions.some((distribution) => distribution.id === "samples" && distribution.buckets.some((bucket) => bucket.label === "低样本"))); assert.ok(summary.workbenchJourneys.some((row) => row.metric === "submit_to_first_visible" && row.backend === "agentrun-v01/codex" && row.p75 >= row.p50 && row.lowSample === true && row.sampleState === "low-sample")); assert.ok(summary.workbenchEventPhases.some((row) => row.phase === "created_to_append" && row.eventType === "backend" && row.transport === "sse")); assert.ok(summary.workbenchBackendEvents.some((row) => row.metric === "backend_event_to_visible" && row.eventType === "terminal" && row.backend === "agentrun-v01/codex")); diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index be719338..ac04408b 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -130,6 +130,27 @@ interface PerformanceSummaryRow { phase?: string; } +interface PerformanceDashboardPoint { + label: string; + detail: string; + value: number; + p50: number; + p75: number; + p95: number; + count: number; + tone: string; + sampleState: string; +} + +interface PerformanceDashboardTopRow { + label: string; + detail: string; + value: number; + unit: string; + count: number; + tone: string; +} + export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) { const env = options.env ?? process.env; const maxSeries = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 240); @@ -339,27 +360,27 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = + histogramSampleCount(eventPhaseSeries) + histogramSampleCount(backendEventVisibleSeries); const totalSampleCount = sampleCount + workbenchSampleCount; - return { - serviceId: "hwlab-cloud-api", - route: "/v1/web-performance/summary", + const summaryInfo = { + sampleCount: totalSampleCount, + sampleSeries: sampleSeries.size, + durationSeries: durationSeries.size, + clsSeries: clsSeries.size, + droppedSeries: droppedSeries.size, + workbenchJourneySeries: journeyDurationSeries.size, + workbenchEventPhaseSeries: eventPhaseSeries.size, + workbenchBackendEventVisibleSeries: backendEventVisibleSeries.size, + routeCount: new Set([...rows, ...workbenchRows].map((row) => row.route)).size, + problemCount: problems.length, + status: totalSampleCount === 0 ? "waiting" : totalSampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok", + lowSampleThreshold: LOW_SAMPLE_THRESHOLD + }; + const observedAt = new Date().toISOString(); + const dashboard = buildPerformanceDashboard({ + observedAt, source: "cloud-api-rum-store", - observedAt: new Date().toISOString(), namespace: baseLabels.namespace, gitopsTarget: baseLabels.gitops_target, - summary: { - sampleCount: totalSampleCount, - sampleSeries: sampleSeries.size, - durationSeries: durationSeries.size, - clsSeries: clsSeries.size, - droppedSeries: droppedSeries.size, - workbenchJourneySeries: journeyDurationSeries.size, - workbenchEventPhaseSeries: eventPhaseSeries.size, - workbenchBackendEventVisibleSeries: backendEventVisibleSeries.size, - routeCount: new Set([...rows, ...workbenchRows].map((row) => row.route)).size, - problemCount: problems.length, - status: totalSampleCount === 0 ? "waiting" : totalSampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok", - lowSampleThreshold: LOW_SAMPLE_THRESHOLD - }, + summary: summaryInfo, apiRoutes, webVitals, longTasks, @@ -367,8 +388,27 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = workbenchJourneys, workbenchEventPhases, workbenchBackendEvents, - workbenchRows: workbenchRows.sort(sortByProblemThenP95).slice(0, 40), - rows: rows.sort(sortByProblemThenP95).slice(0, 40) + rows: rows.sort(sortByProblemThenP95).slice(0, 40), + workbenchRows: workbenchRows.sort(sortByProblemThenP95).slice(0, 40) + }); + return { + serviceId: "hwlab-cloud-api", + route: "/v1/web-performance/summary", + source: "cloud-api-rum-store", + observedAt, + namespace: baseLabels.namespace, + gitopsTarget: baseLabels.gitops_target, + summary: summaryInfo, + dashboard, + apiRoutes, + webVitals, + longTasks, + problems, + workbenchJourneys, + workbenchEventPhases, + workbenchBackendEvents, + workbenchRows: dashboard.source.workbenchRows, + rows: dashboard.source.rows }; } @@ -754,6 +794,240 @@ function toneWeight(tone: string) { return 1; } +function buildPerformanceDashboard(input: { + observedAt: string; + source: string; + namespace: string; + gitopsTarget: string; + summary: Record; + apiRoutes: PerformanceSummaryRow[]; + webVitals: PerformanceSummaryRow[]; + longTasks: PerformanceSummaryRow[]; + problems: PerformanceSummaryRow[]; + workbenchJourneys: PerformanceSummaryRow[]; + workbenchEventPhases: PerformanceSummaryRow[]; + workbenchBackendEvents: PerformanceSummaryRow[]; + rows: PerformanceSummaryRow[]; + workbenchRows: PerformanceSummaryRow[]; +}) { + const allRows = [...input.rows, ...input.workbenchRows]; + const sampleCount = numberField(input.summary.sampleCount); + const problemCount = numberField(input.summary.problemCount); + const status = String(input.summary.status ?? "waiting"); + const trends = [ + dashboardTrend("workbench-experience", "工作台体感趋势", "首屏、会话切换和提交首个可见结果的 P95", "seconds", input.workbenchJourneys), + dashboardTrend("backend-visible", "后端事件可见延迟", "AgentRun/backend 事件到浏览器可见的 P95", "seconds", input.workbenchBackendEvents), + dashboardTrend("api-timing", "同源 API 耗时", "Cloud Web 同源 API 请求 P95", "seconds", input.apiRoutes), + dashboardTrend("web-vitals", "浏览器核心指标", "LCP/INP/CLS/FCP/TTFB 当前窗口", "mixed", input.webVitals) + ].filter((trend) => trend.points.length > 0); + return { + schemaVersion: "hwlab-web-performance-dashboard-v1", + generatedAt: input.observedAt, + sampleWindow: { + label: "当前采集窗口", + bucketKind: "snapshot", + buckets: [{ id: "current", label: "当前窗口", observedAt: input.observedAt, sampleCount }] + }, + freshness: { + observedAt: input.observedAt, + source: input.source, + namespace: input.namespace, + gitopsTarget: input.gitopsTarget, + status, + statusLabel: dashboardStatusLabel(status), + lowSampleThreshold: numberField(input.summary.lowSampleThreshold) + }, + cards: [ + { id: "health", label: "采集状态", value: dashboardStatusLabel(status), unit: "状态", tone: dashboardTone(status), detail: input.source }, + { id: "samples", label: "样本数", value: sampleCount, unit: "样本", tone: sampleCount === 0 ? "source" : "ok", detail: `${numberField(input.summary.sampleSeries)} 个序列` }, + { id: "workbench", label: "工作台序列", value: numberField(input.summary.workbenchJourneySeries) + numberField(input.summary.workbenchEventPhaseSeries) + numberField(input.summary.workbenchBackendEventVisibleSeries), unit: "序列", tone: "ok", detail: `${input.namespace} / ${input.gitopsTarget}` }, + { id: "problems", label: "问题行", value: problemCount, unit: "条", tone: problemCount > 0 ? "warn" : "ok", detail: `低样本阈值 ${numberField(input.summary.lowSampleThreshold)} 个样本` } + ], + trends, + distributions: [ + dashboardDistribution("status", "状态分布", "按健康状态聚合当前窗口行数", countRowsBy(allRows, (row) => toneLabel(row.tone))), + dashboardDistribution("problems", "问题分布", "按问题类型聚合预警和阻塞行", countRowsBy(input.problems, (row) => problemLabel(row.problem || row.outcome))), + dashboardDistribution("samples", "样本状态", "正常、低样本和暂无数据分布", countRowsBy(allRows, (row) => sampleStateLabel(row.sampleState))) + ].filter((item) => item.buckets.length > 0), + topN: [ + dashboardTopN("top-api", "慢 API TopN", "同源 API P95 最慢路由", "seconds", input.apiRoutes), + dashboardTopN("top-workbench", "工作台慢路径 TopN", "工作台 journey 和事件可见延迟", "seconds", [...input.workbenchJourneys, ...input.workbenchBackendEvents]), + dashboardTopN("top-long-task", "长任务 TopN", "主线程长任务当前窗口", "seconds", input.longTasks) + ].filter((item) => item.rows.length > 0), + source: { + rows: input.rows, + workbenchRows: input.workbenchRows + } + }; +} + +function dashboardTrend(id: string, title: string, subtitle: string, unit: string, rows: PerformanceSummaryRow[]) { + return { + id, + title, + subtitle, + unit, + points: rows.slice(0, 8).map(dashboardPoint) + }; +} + +function dashboardPoint(row: PerformanceSummaryRow): PerformanceDashboardPoint { + return { + label: metricLabel(row), + detail: rowDetail(row), + value: row.p95, + p50: row.p50, + p75: row.p75, + p95: row.p95, + count: row.count, + tone: row.tone, + sampleState: row.sampleState ?? "ok" + }; +} + +function dashboardDistribution(id: string, title: string, subtitle: string, counts: Map) { + const buckets = [...counts.entries()] + .map(([label, count]) => ({ label, count, tone: distributionTone(label) })) + .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-Hans-CN")); + return { id, title, subtitle, unit: "count", buckets }; +} + +function dashboardTopN(id: string, title: string, subtitle: string, unit: string, rows: PerformanceSummaryRow[]) { + const topRows: PerformanceDashboardTopRow[] = rows.slice(0, 8).map((row) => ({ + label: metricLabel(row), + detail: rowDetail(row), + value: row.p95, + unit: row.unit === "score" ? "score" : unit, + count: row.count, + tone: row.tone + })); + return { id, title, subtitle, unit, rows: topRows }; +} + +function countRowsBy(rows: PerformanceSummaryRow[], getLabel: (row: PerformanceSummaryRow) => string) { + const counts = new Map(); + for (const row of rows) counts.set(getLabel(row), (counts.get(getLabel(row)) ?? 0) + Math.max(1, row.count)); + return counts; +} + +function metricLabel(row: PerformanceSummaryRow) { + const metric = row.metric || row.phase || row.kind || "unknown"; + const labels: Record = { + submit_to_first_visible: "提交到首个可见结果", + submit_to_failure: "提交失败可见", + backend_event_to_visible: "后端事件到可见", + session_switch_first_visible: "切换会话首个可见", + session_switch_full_load: "切换会话完整加载", + workbench_open_first_visible: "打开工作台首个可见", + workbench_open_full_load: "打开工作台完整加载", + created_to_append: "创建到写入 Trace", + append_to_sse: "Trace 写入到 SSE", + sse_to_receive: "SSE 到浏览器接收", + receive_to_project: "接收到状态投影", + project_to_paint: "投影到画面可见", + user_submit_to_api_accepted: "提交到 API 接收", + api_accepted_to_backend_event: "API 接收到后端事件", + api_request: "同源 API 请求", + long_task: "主线程长任务", + lcp: "最大内容绘制", + inp: "交互响应", + fid: "首次输入延迟", + cls: "布局偏移", + navigation_ttfb: "首字节时间", + navigation_dom_content_loaded: "DOM 内容加载", + navigation_load: "页面加载完成" + }; + return labels[metric] ?? metric.replace(/_/gu, " "); +} + +function rowDetail(row: PerformanceSummaryRow) { + const route = dashboardRouteLabel(row.route); + const parts = [ + route, + row.eventType ? `事件:${eventTypeLabel(row.eventType)}` : "", + row.phase ? `阶段:${metricLabel({ ...row, metric: row.phase })}` : "", + row.backend ? `后端:${row.backend}` : "", + row.transport ? `传输:${transportLabel(row.transport)}` : "", + row.outcome ? `结果:${outcomeLabel(row.outcome)}` : "" + ].filter(Boolean); + return parts.join(" / ") || "当前窗口"; +} + +function dashboardRouteLabel(route: string | undefined) { + const value = String(route ?? "").trim(); + if (!value || value === "-") return ""; + return value + .replace(/:sessionId/gu, ":会话") + .replace(/:traceId/gu, ":轨迹") + .replace(/:runId/gu, ":运行") + .replace(/:threadId/gu, ":线程") + .replace(/:turnId/gu, ":轮次"); +} + +function toneLabel(tone: string | undefined) { + if (tone === "ok") return "正常"; + if (tone === "warn") return "预警"; + if (tone === "blocked") return "阻塞"; + return "等待数据"; +} + +function sampleStateLabel(state: string | undefined) { + if (state === "ok") return "正常样本"; + if (state === "low-sample") return "低样本"; + return "暂无数据"; +} + +function dashboardStatusLabel(status: string) { + if (status === "ok") return "正常"; + if (status === "watch") return "预警"; + if (status === "warming") return "低样本"; + return "等待数据"; +} + +function dashboardTone(status: string) { + if (status === "ok") return "ok"; + if (status === "watch") return "warn"; + return "source"; +} + +function distributionTone(label: string) { + if (label.includes("阻塞")) return "blocked"; + if (label.includes("预警") || label.includes("低样本")) return "warn"; + return "ok"; +} + +function problemLabel(problem: string | undefined) { + const text = String(problem ?? "ok"); + if (text === "ok") return "正常"; + if (text === "low-sample") return "低样本"; + if (text === "timeout") return "超时"; + if (text === "network") return "网络失败"; + if (text === "server-error") return "服务端错误"; + if (text === "client-error") return "客户端错误"; + if (text === "layout-shift") return "布局偏移"; + if (text.startsWith("slow-")) return `慢路径:${metricLabel({ metric: text.slice(5), kind: "", route: "", method: "", statusClass: "", outcome: "", count: 0, average: 0, p50: 0, p75: 0, p95: 0, unit: "seconds", tone: "ok", problem: "" })}`; + return text.replace(/[_-]/gu, " "); +} + +function eventTypeLabel(value: string) { + const labels: Record = { assistant: "助手消息", tool_call: "工具调用", backend: "后端事件", terminal: "终态结果", error: "错误", status: "状态", request: "请求", result: "结果", unknown: "未知" }; + return labels[value] ?? value; +} + +function transportLabel(value: string) { + const labels: Record = { sse: "实时流", rest_gap: "REST 补洞", poll: "轮询", none: "无", unknown: "未知" }; + return labels[value] ?? value; +} + +function outcomeLabel(value: string) { + const labels: Record = { ok: "正常", timeout: "超时", error: "错误", dropped: "丢弃", stale: "滞后", partial: "部分", empty: "空结果", network: "网络", denied: "拒绝", unknown: "未知" }; + return labels[value] ?? value; +} + +function numberField(value: unknown) { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + function roundMetric(value: number) { if (!Number.isFinite(value)) return 0; return Math.round(value * 10000) / 10000; diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 51554d77..f0120ffd 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -257,6 +257,7 @@ function webPerformanceSummaryPayload(): JsonRecord { namespace: "hwlab-v03", gitopsTarget: "D601/v03", summary: { status: "ready", sampleCount: 93, sampleSeries: 12, problemCount: 2, lowSampleThreshold: 5, workbenchJourneySeries: 1, workbenchEventPhaseSeries: 1, workbenchBackendEventVisibleSeries: 1 }, + dashboard: webPerformanceDashboardPayload(rows), rows, problems: rows.filter((row) => row.tone === "warn"), webVitals: rows.filter((row) => row.kind === "web-vital"), @@ -272,6 +273,71 @@ function performanceRow(row: JsonRecord): JsonRecord { return { method: "GET", statusClass: "2xx", outcome: "ok", unit: "seconds", sampleState: "ok", ...row }; } +function webPerformanceDashboardPayload(rows: JsonRecord[]): JsonRecord { + const workbench = rows.filter((row) => String(row.kind).startsWith("workbench")); + const api = rows.filter((row) => row.kind === "api"); + const vital = rows.filter((row) => row.kind === "web-vital"); + const longTask = rows.filter((row) => row.kind === "long-task"); + return { + schemaVersion: "hwlab-web-performance-dashboard-v1", + generatedAt: "2026-06-18T04:30:00.000Z", + sampleWindow: { label: "当前采集窗口", bucketKind: "snapshot", buckets: [{ id: "current", label: "当前窗口", observedAt: "2026-06-18T04:30:00.000Z", sampleCount: 93 }] }, + freshness: { observedAt: "2026-06-18T04:30:00.000Z", source: "fake-server", namespace: "hwlab-v03", gitopsTarget: "D601/v03", status: "watch", statusLabel: "预警", lowSampleThreshold: 5 }, + cards: [ + { id: "health", label: "采集状态", value: "预警", unit: "状态", tone: "warn", detail: "fake-server" }, + { id: "samples", label: "样本数", value: 93, unit: "样本", tone: "ok", detail: "12 个序列" }, + { id: "workbench", label: "工作台序列", value: 3, unit: "序列", tone: "ok", detail: "hwlab-v03 / D601/v03" }, + { id: "problems", label: "问题行", value: 2, unit: "条", tone: "warn", detail: "低样本阈值 5 个样本" } + ], + trends: [ + { id: "workbench-experience", title: "工作台体感趋势", subtitle: "首屏、会话切换和提交首个可见结果的 P95", unit: "seconds", points: workbench.map(dashboardPoint) }, + { id: "api-timing", title: "同源 API 耗时", subtitle: "Cloud Web 同源 API 请求 P95", unit: "seconds", points: api.map(dashboardPoint) }, + { id: "web-vitals", title: "浏览器核心指标", subtitle: "浏览器体感当前窗口", unit: "mixed", points: vital.map(dashboardPoint) } + ], + distributions: [ + { id: "status", title: "状态分布", subtitle: "按健康状态聚合当前窗口行数", unit: "count", buckets: [{ label: "正常", count: 4, tone: "ok" }, { label: "预警", count: 2, tone: "warn" }] }, + { id: "samples", title: "样本状态", subtitle: "正常、低样本和暂无数据分布", unit: "count", buckets: [{ label: "正常样本", count: 6, tone: "ok" }] } + ], + topN: [ + { id: "top-api", title: "慢 API TopN", subtitle: "同源 API P95 最慢路由", unit: "seconds", rows: api.map(dashboardTopRow) }, + { id: "top-workbench", title: "工作台慢路径 TopN", subtitle: "工作台 journey 和事件可见延迟", unit: "seconds", rows: workbench.map(dashboardTopRow) }, + { id: "top-long-task", title: "长任务 TopN", subtitle: "主线程长任务当前窗口", unit: "seconds", rows: longTask.map(dashboardTopRow) } + ] + }; +} + +function dashboardPoint(row: JsonRecord): JsonRecord { + return { label: dashboardMetricLabel(row), detail: dashboardRouteLabel(row.route), value: row.p95, p50: row.p50, p75: row.p75, p95: row.p95, count: row.count, tone: row.tone, sampleState: row.sampleState }; +} + +function dashboardTopRow(row: JsonRecord): JsonRecord { + return { label: dashboardMetricLabel(row), detail: dashboardRouteLabel(row.route), value: row.p95, unit: row.unit ?? "seconds", count: row.count, tone: row.tone }; +} + +function dashboardRouteLabel(route: unknown): string { + const value = String(route ?? "").trim(); + if (!value || value === "-") return "当前窗口"; + return value + .replace(/:sessionId/gu, ":会话") + .replace(/:traceId/gu, ":轨迹") + .replace(/:runId/gu, ":运行") + .replace(/:threadId/gu, ":线程") + .replace(/:turnId/gu, ":轮次"); +} + +function dashboardMetricLabel(row: JsonRecord): string { + const metric = String(row.metric ?? "unknown"); + const labels: Record = { + "session-switch-full-load": "切换会话完整加载", + "trace-event-projected": "轨迹事件投影", + "backend-event-visible": "后端事件到可见", + LCP: "最大内容绘制", + fetchJson: "同源 API 请求", + longtask: "主线程长任务" + }; + return labels[metric] ?? metric; +} + function createScenarioState(scenarioId: string): ScenarioState { const base = structuredClone(capture.scenario); const id = scenarioId || "baseline"; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index f681852d..05f6668d 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -2426,6 +2426,258 @@ text-align: right; } +.performance-health-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.performance-health-card { + min-height: 112px; + border-left: 4px solid #64748b; +} + +.performance-health-card.is-ok { + border-left-color: #0f766e; +} + +.performance-health-card.is-warn { + border-left-color: #b45309; +} + +.performance-health-card.is-blocked { + border-left-color: #b91c1c; +} + +.performance-freshness-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #f8fafc; + padding: 12px 14px; +} + +.performance-freshness-panel div { + display: grid; + gap: 3px; + min-width: 0; +} + +.performance-freshness-panel strong { + color: #0f172a; + font-size: 14px; +} + +.performance-freshness-panel span, +.performance-chart-card header span, +.performance-top-row span, +.performance-chart-legend span { + color: #64748b; + font-size: 12px; +} + +.performance-diagnostic-panel { + padding: 18px; +} + +.performance-chart-grid, +.performance-analytics-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.performance-chart-card { + display: grid; + min-width: 0; + gap: 12px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #ffffff; + padding: 14px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.performance-chart-card header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.performance-chart-card header div { + display: grid; + gap: 3px; + min-width: 0; +} + +.performance-chart-card header strong { + color: #0f172a; + font-size: 14px; +} + +.performance-chart-card header em { + flex: 0 0 auto; + border: 1px solid #cbd5e1; + border-radius: 999px; + color: #475569; + font-size: 12px; + font-style: normal; + line-height: 1; + padding: 5px 8px; +} + +.performance-line-chart { + width: 100%; + height: 150px; + overflow: visible; +} + +.chart-grid-line { + fill: none; + stroke: #e2e8f0; + stroke-width: 1; +} + +.chart-line { + fill: none; + stroke: #0f766e; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chart-point { + fill: #64748b; + stroke: #ffffff; + stroke-width: 2; +} + +.chart-point.is-ok, +.performance-bar-row i.is-ok, +.performance-top-row i.is-ok { + fill: #0f766e; + background: #0f766e; +} + +.chart-point.is-warn, +.performance-bar-row i.is-warn, +.performance-top-row i.is-warn { + fill: #b45309; + background: #b45309; +} + +.chart-point.is-blocked, +.performance-bar-row i.is-blocked, +.performance-top-row i.is-blocked { + fill: #b91c1c; + background: #b91c1c; +} + +.performance-chart-legend { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.performance-chart-legend li { + display: grid; + min-width: 0; + gap: 2px; + border-top: 1px solid #e2e8f0; + padding-top: 8px; +} + +.performance-chart-legend strong { + color: #0f172a; + font-size: 13px; +} + +.performance-bar-list, +.performance-top-list { + display: grid; + gap: 10px; +} + +.performance-bar-row { + display: grid; + grid-template-columns: minmax(80px, 128px) minmax(0, 1fr) 48px; + align-items: center; + gap: 10px; +} + +.performance-bar-row > span { + min-width: 0; + overflow: hidden; + color: #334155; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-bar-row div, +.performance-top-row > div:nth-child(2) { + height: 9px; + overflow: hidden; + border-radius: 999px; + background: #e2e8f0; +} + +.performance-bar-row i, +.performance-top-row i { + display: block; + height: 100%; + min-width: 6px; + border-radius: inherit; + background: #64748b; +} + +.performance-bar-row strong { + color: #0f172a; + font-size: 12px; + text-align: right; +} + +.performance-top-row { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(96px, 0.8fr) minmax(70px, auto); + align-items: center; + gap: 10px; +} + +.performance-top-row > div:first-child { + display: grid; + min-width: 0; + gap: 2px; +} + +.performance-top-row strong, +.performance-top-row span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-top-row > div:first-child strong { + color: #0f172a; + font-size: 12px; +} + +.performance-top-row em { + color: #0f172a; + font-size: 12px; + font-style: normal; + text-align: right; +} + .system-split-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -2598,7 +2850,10 @@ .admin-users-layout, .admin-detail-grid, .system-summary-grid, - .system-split-grid { + .system-split-grid, + .performance-health-strip, + .performance-chart-grid, + .performance-analytics-grid { grid-template-columns: 1fr; } @@ -2758,7 +3013,37 @@ .system-filter-bar, .rum-bar-row, + .performance-freshness-panel, + .performance-bar-row, + .performance-top-row, .skill-tree-list li { grid-template-columns: 1fr; } + + .performance-health-card { + min-height: 92px; + } + + .performance-chart-card header, + .performance-freshness-panel { + align-items: stretch; + } + + .performance-chart-card header, + .performance-freshness-panel, + .performance-top-row { + gap: 8px; + } + + .performance-chart-legend { + grid-template-columns: 1fr; + } + + .performance-line-chart { + height: 128px; + } + + .performance-top-row em { + text-align: left; + } } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 95edcbbb..a7875ac9 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -347,6 +347,77 @@ export interface WebPerformanceRow extends Record { phase?: string; } +export interface WebPerformanceDashboardPoint extends Record { + label?: string; + detail?: string; + value?: number; + p50?: number; + p75?: number; + p95?: number; + count?: number; + tone?: string; + sampleState?: string; +} + +export interface WebPerformanceDashboardTrend extends Record { + id?: string; + title?: string; + subtitle?: string; + unit?: string; + points?: WebPerformanceDashboardPoint[]; +} + +export interface WebPerformanceDashboardDistributionBucket extends Record { + label?: string; + count?: number; + tone?: string; +} + +export interface WebPerformanceDashboardDistribution extends Record { + id?: string; + title?: string; + subtitle?: string; + unit?: string; + buckets?: WebPerformanceDashboardDistributionBucket[]; +} + +export interface WebPerformanceDashboardTopRow extends Record { + label?: string; + detail?: string; + value?: number; + unit?: string; + count?: number; + tone?: string; +} + +export interface WebPerformanceDashboardTopN extends Record { + id?: string; + title?: string; + subtitle?: string; + unit?: string; + rows?: WebPerformanceDashboardTopRow[]; +} + +export interface WebPerformanceDashboardCard extends Record { + id?: string; + label?: string; + value?: string | number; + unit?: string; + tone?: string; + detail?: string; +} + +export interface WebPerformanceDashboard extends Record { + schemaVersion?: string; + generatedAt?: string; + sampleWindow?: { label?: string; bucketKind?: string; buckets?: Array>; [key: string]: unknown }; + freshness?: { observedAt?: string; source?: string; namespace?: string; gitopsTarget?: string; status?: string; statusLabel?: string; lowSampleThreshold?: number; [key: string]: unknown }; + cards?: WebPerformanceDashboardCard[]; + trends?: WebPerformanceDashboardTrend[]; + distributions?: WebPerformanceDashboardDistribution[]; + topN?: WebPerformanceDashboardTopN[]; +} + export interface WebPerformanceSummaryResponse { status?: string; source?: string; @@ -354,6 +425,7 @@ export interface WebPerformanceSummaryResponse { namespace?: string; gitopsTarget?: string; summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; lowSampleThreshold?: number; [key: string]: unknown }; + dashboard?: WebPerformanceDashboard; apiRoutes?: WebPerformanceRow[]; webVitals?: WebPerformanceRow[]; longTasks?: WebPerformanceRow[]; diff --git a/web/hwlab-cloud-web/src/views/PerformanceView.vue b/web/hwlab-cloud-web/src/views/PerformanceView.vue index a444b7db..da23a46d 100644 --- a/web/hwlab-cloud-web/src/views/PerformanceView.vue +++ b/web/hwlab-cloud-web/src/views/PerformanceView.vue @@ -9,27 +9,23 @@ import LoadingState from "@/components/common/LoadingState.vue"; import PageHeader from "@/components/common/PageHeader.vue"; import TablePageLayout from "@/components/layout/TablePageLayout.vue"; import { useTableLoader } from "@/composables/useTableLoader"; -import type { WebPerformanceRow, WebPerformanceSummaryResponse } from "@/types"; +import type { + WebPerformanceDashboardDistribution, + WebPerformanceDashboardPoint, + WebPerformanceDashboardTopN, + WebPerformanceDashboardTrend, + WebPerformanceRow, + WebPerformanceSummaryResponse +} from "@/types"; -const columns: DataTableColumn[] = [ - { key: "metric", label: "Metric" }, - { key: "route", label: "Route" }, - { key: "count", label: "Count", align: "right" }, +const detailColumns: DataTableColumn[] = [ + { key: "metric", label: "指标" }, + { key: "route", label: "路由与维度" }, + { key: "count", label: "样本", align: "right" }, { key: "p50", label: "P50", align: "right" }, { key: "p75", label: "P75", align: "right" }, { key: "p95", label: "P95", align: "right" }, - { key: "tone", label: "状态" } -]; - -const workbenchColumns: DataTableColumn[] = [ - { key: "metric", label: "Metric" }, - { key: "route", label: "Route" }, - { key: "dimensions", label: "维度" }, - { key: "count", label: "Count", align: "right" }, - { key: "p50", label: "P50", align: "right" }, - { key: "p75", label: "P75", align: "right" }, - { key: "p95", label: "P95", align: "right" }, - { key: "tone", label: "状态" } + { key: "tone", label: "健康状态" } ]; const payload = ref(null); @@ -41,154 +37,358 @@ const table = useTableLoader(async () => { }); const summary = computed(() => payload.value?.summary ?? {}); +const dashboard = computed(() => payload.value?.dashboard ?? null); +const cards = computed(() => dashboard.value?.cards ?? []); +const trends = computed(() => dashboard.value?.trends ?? []); +const distributions = computed(() => dashboard.value?.distributions ?? []); +const topN = computed(() => dashboard.value?.topN ?? []); const problems = computed(() => payload.value?.problems ?? []); -const webVitals = computed(() => payload.value?.webVitals ?? []); -const apiRoutes = computed(() => payload.value?.apiRoutes ?? []); -const longTasks = computed(() => payload.value?.longTasks ?? []); -const workbenchJourneys = computed(() => payload.value?.workbenchJourneys ?? []); -const workbenchEventPhases = computed(() => payload.value?.workbenchEventPhases ?? []); -const workbenchBackendEvents = computed(() => payload.value?.workbenchBackendEvents ?? []); -const workbenchSeriesCount = computed(() => Number(summary.value.workbenchJourneySeries ?? 0) + Number(summary.value.workbenchEventPhaseSeries ?? 0) + Number(summary.value.workbenchBackendEventVisibleSeries ?? 0)); -const chartRows = computed(() => [ - { label: "Workbench", value: workbenchJourneys.value.length + workbenchEventPhases.value.length + workbenchBackendEvents.value.length }, - { label: "Web Vitals", value: webVitals.value.length }, - { label: "API Timing", value: apiRoutes.value.length }, - { label: "Long Tasks", value: longTasks.value.length }, - { label: "Problems", value: problems.value.length } -]); -const maxChartValue = computed(() => Math.max(1, ...chartRows.value.map((row) => row.value))); +const detailRows = computed(() => { + if (problems.value.length) return problems.value; + return [...(payload.value?.workbenchRows ?? []), ...(payload.value?.rows ?? [])]; +}); +const hasDashboard = computed(() => dashboard.value?.schemaVersion === "hwlab-web-performance-dashboard-v1"); +const freshnessText = computed(() => { + const freshness = dashboard.value?.freshness; + const observedAt = formatTime(freshness?.observedAt ?? payload.value?.observedAt); + const source = freshness?.source ?? payload.value?.source ?? "-"; + const namespace = freshness?.namespace ?? payload.value?.namespace ?? "-"; + const target = freshness?.gitopsTarget ?? payload.value?.gitopsTarget ?? "-"; + return `${observedAt} · ${source} · ${namespace} / ${target}`; +}); onMounted(() => void table.reload()); function rowKey(row: WebPerformanceRow): string { - return `${row.kind ?? 'kind'}:${row.metric ?? 'metric'}:${row.route ?? 'route'}:${row.method ?? 'method'}:${row.statusClass ?? 'status'}:${row.backend ?? 'backend'}:${row.transport ?? 'transport'}:${row.eventType ?? 'event'}:${row.targetState ?? 'target'}:${row.cache ?? 'cache'}`; + return `${row.kind ?? "kind"}:${row.metric ?? "metric"}:${row.route ?? "route"}:${row.method ?? "method"}:${row.statusClass ?? "status"}:${row.backend ?? "backend"}:${row.transport ?? "transport"}:${row.eventType ?? "event"}:${row.phase ?? "phase"}`; } -function formatValue(row: WebPerformanceRow, key: "average" | "p50" | "p75" | "p95"): string { +function displayText(value?: string | null): string { + return String(value ?? "") + .replace(/:sessionId/gu, ":会话") + .replace(/:traceId/gu, ":轨迹") + .replace(/:runId/gu, ":运行") + .replace(/:threadId/gu, ":线程") + .replace(/:turnId/gu, ":轮次") + .replace(/\bsessionId\b/gu, "会话") + .replace(/\btraceId\b/gu, "轨迹") + .replace(/\brunId\b/gu, "运行") + .replace(/\bthreadId\b/gu, "线程") + .replace(/\bturnId\b/gu, "轮次") + .replace(/\bstdout\b/gu, "标准输出") + .replace(/\bstderr\b/gu, "错误输出") + .replace(/\bSecret\b/gu, "密钥"); +} + +function displayRoute(value?: string | null): string { + const text = displayText(value).trim(); + return text || "当前窗口"; +} + +function metricLabel(row: WebPerformanceRow): string { + const metric = String(row.phase || row.metric || row.kind || "unknown").replace(/-/gu, "_"); + const labels: Record = { + submit_to_first_visible: "提交到首个可见结果", + submit_to_failure: "提交失败可见", + backend_event_to_visible: "后端事件到可见", + session_switch_first_visible: "切换会话首个可见", + session_switch_full_load: "切换会话完整加载", + workbench_open_first_visible: "打开工作台首个可见", + workbench_open_full_load: "打开工作台完整加载", + created_to_append: "创建到写入 Trace", + append_to_sse: "Trace 写入到实时流", + sse_to_receive: "实时流到浏览器", + receive_to_project: "接收到状态投影", + project_to_paint: "投影到画面可见", + user_submit_to_api_accepted: "提交到 API 接收", + api_accepted_to_backend_event: "API 接收到后端事件", + api_request: "同源 API 请求", + long_task: "主线程长任务", + lcp: "最大内容绘制", + inp: "交互响应", + fid: "首次输入延迟", + cls: "布局偏移", + navigation_ttfb: "首字节时间", + navigation_dom_content_loaded: "DOM 内容加载", + navigation_load: "页面加载完成", + trace_event_projected: "轨迹事件投影", + backend_event_visible: "后端事件到可见", + longtask: "主线程长任务" + }; + return labels[metric] ?? metric.replace(/[_-]/gu, " "); +} + +function kindLabel(kind?: string): string { + const labels: Record = { + workbench_journey: "工作台体感", + workbench_event_phase: "事件阶段", + workbench_backend_event_visible: "后端可见", + web_vital: "浏览器体感", + navigation: "页面加载", + api: "同源 API", + long_task: "长任务" + }; + return labels[String(kind ?? "")] ?? "性能样本"; +} + +function formatValue(row: WebPerformanceRow | WebPerformanceDashboardPoint, key: "value" | "p50" | "p75" | "p95" = "value", unit?: string): string { const value = Number(row[key]); if (!Number.isFinite(value)) return "-"; - if (row.unit === "score") return value.toFixed(3); - return `${Math.round(value * 1000)}ms`; + const resolvedUnit = unit ?? ("unit" in row && typeof row.unit === "string" ? row.unit : undefined); + if (resolvedUnit === "score") return value.toFixed(3); + if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 1)} 秒`; + return `${Math.round(value * 1000)} 毫秒`; } -function formatCount(value?: number): string { - return Number.isFinite(Number(value)) ? String(value) : "0"; +function formatCount(value?: number | string): string { + const number = Number(value); + return Number.isFinite(number) ? number.toLocaleString("zh-CN") : "0"; +} + +function formatCardValue(value?: string | number, unit?: string): string { + if (typeof value === "number") return `${value.toLocaleString("zh-CN")}${unit && unit !== "状态" ? ` ${unit}` : ""}`; + return String(value ?? "-"); +} + +function formatTime(value?: string): string { + if (!value) return "未采集"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(date); } function formatDimensions(row: WebPerformanceRow): string { const parts = [ - row.backend ? `backend ${row.backend}` : "", - row.transport ? `transport ${row.transport}` : "", - row.eventType ? `event ${row.eventType}` : "", - row.targetState ? `target ${row.targetState}` : "", - row.cache ? `cache ${row.cache}` : "", - row.source ? `source ${row.source}` : "", - row.entry ? `entry ${row.entry}` : "" + row.backend ? `后端:${row.backend}` : "", + row.transport ? `传输:${transportLabel(row.transport)}` : "", + row.eventType ? `事件:${eventTypeLabel(row.eventType)}` : "", + row.targetState ? `目标:${targetStateLabel(row.targetState)}` : "", + row.cache ? `缓存:${cacheLabel(row.cache)}` : "", + row.source ? `来源:${sourceLabel(row.source)}` : "", + row.entry ? `入口:${entryLabel(row.entry)}` : "", + row.outcome ? `结果:${outcomeLabel(row.outcome)}` : "" ].filter(Boolean); - return parts.length ? parts.join(" / ") : "-"; + return parts.length ? parts.join(" / ") : "当前窗口"; } -function formatSampleState(row: WebPerformanceRow): string { - if (row.sampleState === "low-sample") return `low < ${formatCount(summary.value.lowSampleThreshold as number)}`; - return row.sampleState || "ok"; +function problemLabel(problem?: string): string { + const text = String(problem ?? ""); + const labels: Record = { + ok: "正常", + "low-sample": "低样本", + timeout: "超时", + network: "网络失败", + "server-error": "服务端错误", + "client-error": "客户端错误", + "layout-shift": "布局偏移", + "main-thread busy": "主线程繁忙" + }; + if (labels[text]) return labels[text]; + if (text.startsWith("slow-")) return `慢路径:${metricLabel({ metric: text.slice(5) } as WebPerformanceRow)}`; + return text.replace(/[_-]/gu, " "); +} + +function statusText(row: WebPerformanceRow): string { + return `${toneLabel(row.tone)} · ${sampleStateLabel(row.sampleState)}`; +} + +function toneLabel(tone?: string): string { + if (tone === "ok") return "正常"; + if (tone === "warn") return "预警"; + if (tone === "blocked") return "阻塞"; + return "等待数据"; +} + +function sampleStateLabel(state?: string): string { + if (state === "ok") return "正常样本"; + if (state === "low-sample") return "低样本"; + return "暂无数据"; +} + +function outcomeLabel(value?: string): string { + const labels: Record = { ok: "正常", timeout: "超时", error: "错误", dropped: "丢弃", stale: "滞后", partial: "部分", empty: "空结果", network: "网络", denied: "拒绝", unknown: "未知" }; + return labels[String(value ?? "unknown")] ?? String(value); +} + +function transportLabel(value?: string): string { + const labels: Record = { sse: "实时流", rest_gap: "REST 补洞", poll: "轮询", none: "无", unknown: "未知" }; + return labels[String(value ?? "unknown")] ?? String(value); +} + +function eventTypeLabel(value?: string): string { + if (String(value ?? "").startsWith("agentrun.")) return "后端输出"; + const labels: Record = { assistant: "助手消息", tool_call: "工具调用", backend: "后端事件", terminal: "终态结果", error: "错误", status: "状态", request: "请求", result: "结果", unknown: "未知" }; + return labels[String(value ?? "unknown")] ?? String(value); +} + +function targetStateLabel(value?: string): string { + const labels: Record = { running: "运行中", terminal: "已终态", completed: "已完成", empty: "空会话", unknown: "未知" }; + return labels[String(value ?? "unknown")] ?? String(value); +} + +function cacheLabel(value?: string): string { + const labels: Record = { warm: "热缓存", cold: "冷加载", hit: "命中", miss: "未命中", unknown: "未知" }; + return labels[String(value ?? "unknown")] ?? String(value); +} + +function sourceLabel(value?: string): string { + const labels: Record = { rail: "会话栏", deeplink: "深链", history: "历史", direct: "直达", hydrate: "恢复", unknown: "未知" }; + return labels[String(value ?? "unknown")] ?? String(value); +} + +function entryLabel(value?: string): string { + const labels: Record = { new: "新会话", existing: "已有会话", steer: "续写", retry: "重试", unknown: "未知" }; + return labels[String(value ?? "unknown")] ?? String(value); +} + +function trendPoints(trend: WebPerformanceDashboardTrend): WebPerformanceDashboardPoint[] { + return Array.isArray(trend.points) ? trend.points : []; +} + +function linePoints(trend: WebPerformanceDashboardTrend): string { + const points = trendPoints(trend); + if (!points.length) return ""; + return points.map((point, index) => `${chartX(index, points.length)},${chartY(Number(point.value), points)}`).join(" "); +} + +function chartX(index: number, count: number): number { + if (count <= 1) return 150; + return Math.round(22 + (index / (count - 1)) * 256); +} + +function chartY(value: number, points: WebPerformanceDashboardPoint[]): number { + const values = points.map((point) => Number(point.value)).filter(Number.isFinite); + const max = Math.max(...values, 0.001); + const normalized = Number.isFinite(value) ? value / max : 0; + return Math.round(86 - normalized * 58); +} + +function barPercent(value?: number, max = 1): number { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) return 4; + return Math.max(6, Math.min(100, (number / Math.max(1, max)) * 100)); +} + +function distributionMax(distribution: WebPerformanceDashboardDistribution): number { + return Math.max(1, ...(distribution.buckets ?? []).map((bucket) => Number(bucket.count) || 0)); +} + +function topMax(group: WebPerformanceDashboardTopN): number { + return Math.max(0.001, ...(group.rows ?? []).map((row) => Number(row.value) || 0)); +} + +function toneClass(tone?: string): string { + if (tone === "blocked") return "is-blocked"; + if (tone === "warn") return "is-warn"; + if (tone === "ok") return "is-ok"; + return "is-source"; } diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts index cdf2f8c9..d8223f21 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts @@ -16,19 +16,32 @@ test("admin HWPOD groups renders node-ops as structured UI before raw JSON is re await expect(page.getByTestId("hwpod-raw-json")).toContainText('"contractVersion"'); }); -test("performance tables keep wide columns reachable inside local mobile scrollers", async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); +test("performance dashboard renders Chinese charts and keeps drill-down reachable", async ({ page }) => { await page.goto("/performance"); await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); - await expect(page.getByText("Problem rows")).toBeVisible(); + await expect(page.getByText("工作台体感趋势")).toBeVisible(); + await expect(page.getByText("状态分布")).toBeVisible(); + await expect(page.getByText("慢 API TopN")).toBeVisible(); + await expect(page.locator("svg.performance-line-chart")).toHaveCount(3); + await expect(page.locator(".chart-point")).not.toHaveCount(0); + await expect(page.locator("body")).not.toContainText(/Metric|Route|Count|Status|Samples|Problems|Workbench|Web Vitals|API Timing|Long Tasks/u); + await expect(page.locator("body")).not.toContainText(/sessionId|traceId|runId|stdout|stderr|Secret/u); + + await page.screenshot({ path: ".state/workbench-e2e/performance-dashboard-desktop.png", fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/performance"); + await expect(page.getByText("性能明细")).toBeVisible(); + await expect(page.locator("svg.performance-line-chart")).toHaveCount(3); + await page.screenshot({ path: ".state/workbench-e2e/performance-dashboard-mobile.png", fullPage: true }); const scrollers = await page.locator(".performance-page .data-table-wrap").evaluateAll((items) => items.map((item) => ({ clientWidth: Math.round(item.clientWidth), scrollWidth: Math.round(item.scrollWidth), overflowX: getComputedStyle(item).overflowX }))); - expect(scrollers.length).toBeGreaterThanOrEqual(4); + expect(scrollers.length).toBeGreaterThanOrEqual(1); expect(scrollers.every((item) => item.clientWidth <= 390)).toBeTruthy(); expect(scrollers.some((item) => item.scrollWidth > item.clientWidth + 120)).toBeTruthy(); expect(scrollers.every((item) => item.overflowX === "auto" || item.overflowX === "scroll")).toBeTruthy();