Merge pull request #1611 from pikasTech/feat/1609-performance-dashboard

feat: add workbench performance dashboard
This commit is contained in:
Lyon
2026-06-19 16:50:45 +08:00
committed by GitHub
7 changed files with 1072 additions and 155 deletions
+7
View File
@@ -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"));
+294 -20
View File
@@ -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<string, number | string>;
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<string, number>) {
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<string, number>();
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<string, string> = {
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<string, string> = { assistant: "助手消息", tool_call: "工具调用", backend: "后端事件", terminal: "终态结果", error: "错误", status: "状态", request: "请求", result: "结果", unknown: "未知" };
return labels[value] ?? value;
}
function transportLabel(value: string) {
const labels: Record<string, string> = { sse: "实时流", rest_gap: "REST 补洞", poll: "轮询", none: "无", unknown: "未知" };
return labels[value] ?? value;
}
function outcomeLabel(value: string) {
const labels: Record<string, string> = { 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;
@@ -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<string, string> = {
"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";
+286 -1
View File
@@ -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;
}
}
+72
View File
@@ -347,6 +347,77 @@ export interface WebPerformanceRow extends Record<string, unknown> {
phase?: string;
}
export interface WebPerformanceDashboardPoint extends Record<string, unknown> {
label?: string;
detail?: string;
value?: number;
p50?: number;
p75?: number;
p95?: number;
count?: number;
tone?: string;
sampleState?: string;
}
export interface WebPerformanceDashboardTrend extends Record<string, unknown> {
id?: string;
title?: string;
subtitle?: string;
unit?: string;
points?: WebPerformanceDashboardPoint[];
}
export interface WebPerformanceDashboardDistributionBucket extends Record<string, unknown> {
label?: string;
count?: number;
tone?: string;
}
export interface WebPerformanceDashboardDistribution extends Record<string, unknown> {
id?: string;
title?: string;
subtitle?: string;
unit?: string;
buckets?: WebPerformanceDashboardDistributionBucket[];
}
export interface WebPerformanceDashboardTopRow extends Record<string, unknown> {
label?: string;
detail?: string;
value?: number;
unit?: string;
count?: number;
tone?: string;
}
export interface WebPerformanceDashboardTopN extends Record<string, unknown> {
id?: string;
title?: string;
subtitle?: string;
unit?: string;
rows?: WebPerformanceDashboardTopRow[];
}
export interface WebPerformanceDashboardCard extends Record<string, unknown> {
id?: string;
label?: string;
value?: string | number;
unit?: string;
tone?: string;
detail?: string;
}
export interface WebPerformanceDashboard extends Record<string, unknown> {
schemaVersion?: string;
generatedAt?: string;
sampleWindow?: { label?: string; bucketKind?: string; buckets?: Array<Record<string, unknown>>; [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[];
+330 -130
View File
@@ -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<WebPerformanceSummaryResponse | null>(null);
@@ -41,154 +37,358 @@ const table = useTableLoader<WebPerformanceRow>(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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = { 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<string, string> = { 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<string, string> = { 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<string, string> = { running: "运行中", terminal: "已终态", completed: "已完成", empty: "空会话", unknown: "未知" };
return labels[String(value ?? "unknown")] ?? String(value);
}
function cacheLabel(value?: string): string {
const labels: Record<string, string> = { warm: "热缓存", cold: "冷加载", hit: "命中", miss: "未命中", unknown: "未知" };
return labels[String(value ?? "unknown")] ?? String(value);
}
function sourceLabel(value?: string): string {
const labels: Record<string, string> = { rail: "会话栏", deeplink: "深链", history: "历史", direct: "直达", hydrate: "恢复", unknown: "未知" };
return labels[String(value ?? "unknown")] ?? String(value);
}
function entryLabel(value?: string): string {
const labels: Record<string, string> = { 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";
}
</script>
<template>
<section class="route-stack system-page performance-page">
<PageHeader eyebrow="System" title="性能监控" description="RUM 已在 Vue 入口安装,页面汇总真实浏览器 Workbench、navigation、Web Vital、API timing 和 long task 样本。" />
<PageHeader eyebrow="运维监控" title="性能监控" description="以同一性能摘要展示工作台体感、后端事件可见延迟、API 耗时、浏览器指标和采集健康。" />
<LoadingState v-if="table.loading.value && !table.rows.value.length" />
<section v-else-if="table.error.value" class="data-panel">
<EmptyState title="Performance 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
<EmptyState title="性能监控加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
</section>
<template v-else>
<section class="system-summary-grid" aria-label="Performance summary">
<article class="metric-card"><span>Status</span><strong>{{ summary.status || 'waiting' }}</strong><small>{{ payload?.source || '-' }}</small></article>
<article class="metric-card"><span>Samples</span><strong>{{ formatCount(summary.sampleCount as number) }}</strong><small>{{ formatCount(summary.sampleSeries as number) }} series</small></article>
<article class="metric-card"><span>Workbench</span><strong>{{ formatCount(workbenchSeriesCount) }}</strong><small>{{ payload?.namespace || '-' }} / {{ payload?.gitopsTarget || '-' }}</small></article>
<article class="metric-card"><span>Problems</span><strong>{{ formatCount(summary.problemCount as number) }}</strong><small>{{ payload?.observedAt || '-' }}</small></article>
<section class="performance-health-strip" aria-label="性能概览">
<article v-for="card in cards" :key="card.id" class="metric-card performance-health-card" :class="toneClass(String(card.tone ?? 'source'))">
<span>{{ card.label }}</span>
<strong>{{ formatCardValue(card.value, card.unit) }}</strong>
<small>{{ card.detail || freshnessText }}</small>
</article>
</section>
<section class="data-panel rum-chart-panel" aria-label="Performance chart">
<div v-for="item in chartRows" :key="item.label" class="rum-bar-row">
<span>{{ item.label }}</span>
<div><i :style="{ width: `${Math.max(6, (item.value / maxChartValue) * 100)}%` }" /></div>
<strong>{{ item.value }}</strong>
</div>
<section v-if="!hasDashboard" class="data-panel performance-diagnostic-panel">
<EmptyState title="等待图表数据契约" description="summary 已返回,但缺少 dashboard 字段。请先发布包含 chart-ready summary 的 Cloud API,再观察图表。" action-label="刷新" @action="table.reload" />
</section>
<TablePageLayout title="Problem rows" subtitle="按后端 summary 的 warn/blocked/outcome 聚合">
<template v-else>
<section class="performance-freshness-panel" aria-label="采集新鲜度">
<div>
<strong>{{ dashboard?.freshness?.statusLabel || '等待数据' }}</strong>
<span>{{ freshnessText }}</span>
</div>
<button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button>
</section>
<section class="performance-chart-grid" aria-label="性能趋势图">
<article v-for="trend in trends" :key="trend.id" class="performance-chart-card">
<header>
<div>
<strong>{{ trend.title }}</strong>
<span>{{ trend.subtitle }}</span>
</div>
<em>{{ trendPoints(trend).length }} 条序列</em>
</header>
<svg class="performance-line-chart" viewBox="0 0 300 104" role="img" :aria-label="`${trend.title || '性能'}趋势图`">
<path d="M22 28 H278 M22 57 H278 M22 86 H278" class="chart-grid-line" />
<polyline v-if="linePoints(trend)" :points="linePoints(trend)" class="chart-line" />
<circle v-for="(point, index) in trendPoints(trend)" :key="`${trend.id}-${point.label}-${index}`" :cx="chartX(index, trendPoints(trend).length)" :cy="chartY(Number(point.value), trendPoints(trend))" r="4" :class="['chart-point', toneClass(point.tone)]">
<title>{{ point.label }}{{ formatValue(point, 'value', trend.unit) }}{{ formatCount(point.count) }} 个样本</title>
</circle>
</svg>
<ol class="performance-chart-legend">
<li v-for="point in trendPoints(trend).slice(0, 4)" :key="`${trend.id}-${point.label}`">
<span>{{ point.label }}</span>
<strong>{{ formatValue(point, 'value', trend.unit) }}</strong>
</li>
</ol>
</article>
</section>
<section class="performance-analytics-grid" aria-label="性能分布与排行">
<article v-for="distribution in distributions" :key="distribution.id" class="performance-chart-card performance-bar-card">
<header>
<div>
<strong>{{ distribution.title }}</strong>
<span>{{ distribution.subtitle }}</span>
</div>
</header>
<div class="performance-bar-list">
<div v-for="bucket in distribution.buckets" :key="`${distribution.id}-${bucket.label}`" class="performance-bar-row">
<span>{{ bucket.label }}</span>
<div><i :class="toneClass(bucket.tone)" :style="{ width: `${barPercent(Number(bucket.count), distributionMax(distribution))}%` }" /></div>
<strong>{{ formatCount(bucket.count) }}</strong>
</div>
</div>
</article>
<article v-for="group in topN" :key="group.id" class="performance-chart-card performance-top-card">
<header>
<div>
<strong>{{ group.title }}</strong>
<span>{{ group.subtitle }}</span>
</div>
</header>
<div class="performance-top-list">
<div v-for="row in group.rows" :key="`${group.id}-${row.label}-${row.detail}`" class="performance-top-row">
<div>
<strong>{{ row.label }}</strong>
<span>{{ displayText(row.detail) }}</span>
</div>
<div><i :class="toneClass(row.tone)" :style="{ width: `${barPercent(Number(row.value), topMax(group))}%` }" /></div>
<em>{{ formatValue(row, 'value', row.unit) }}</em>
</div>
</div>
</article>
</section>
</template>
<TablePageLayout title="性能明细" subtitle="来自同一 summary 的问题行和可访问表格 drill-down">
<template #actions><button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button></template>
<DataTable :columns="columns" :rows="problems.length ? problems : table.rows.value" :row-key="rowKey" empty-text="等待 RUM 样本浏览几个页面后刷新即可看到数据">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).method || 'GET' }} / {{ (row as WebPerformanceRow).statusClass || '-' }} / {{ (row as WebPerformanceRow).outcome || '-' }}</small></template>
<DataTable :columns="detailColumns" :rows="detailRows" :row-key="rowKey" empty-text="暂无性能样本请先打开工作台或执行一次真实页面操作后刷新">
<template #cell-metric="{ row }"><strong>{{ metricLabel(row as WebPerformanceRow) }}</strong><small>{{ kindLabel((row as WebPerformanceRow).kind) }}</small></template>
<template #cell-route="{ row }"><code>{{ displayRoute((row as WebPerformanceRow).route) }}</code><small>{{ formatDimensions(row as WebPerformanceRow) }}</small></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p75="{ row }">{{ formatValue(row as WebPerformanceRow, 'p75') }}</template>
<template #cell-p95="{ row }"><strong>{{ formatValue(row as WebPerformanceRow, 'p95') }}</strong><small>{{ (row as WebPerformanceRow).problem || '' }}</small></template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span><small>{{ formatSampleState(row as WebPerformanceRow) }}</small></template>
<template #cell-p95="{ row }"><strong>{{ formatValue(row as WebPerformanceRow, 'p95') }}</strong><small>{{ (row as WebPerformanceRow).problem ? `诊断:${problemLabel((row as WebPerformanceRow).problem)}` : '' }}</small></template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ statusText(row as WebPerformanceRow) }}</span></template>
</DataTable>
</TablePageLayout>
<section class="system-split-grid performance-workbench-grid">
<TablePageLayout title="Workbench journeys" subtitle="首屏、session switch、submit first visible">
<DataTable :columns="workbenchColumns" :rows="workbenchJourneys" :row-key="rowKey" empty-text="暂无 Workbench journey 样本">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).outcome || '-' }}</small></template>
<template #cell-dimensions="{ row }"><span class="performance-dimensions">{{ formatDimensions(row as WebPerformanceRow) }}</span></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p75="{ row }">{{ formatValue(row as WebPerformanceRow, 'p75') }}</template>
<template #cell-p95="{ row }"><strong>{{ formatValue(row as WebPerformanceRow, 'p95') }}</strong><small>{{ (row as WebPerformanceRow).problem || '' }}</small></template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span><small>{{ formatSampleState(row as WebPerformanceRow) }}</small></template>
</DataTable>
</TablePageLayout>
<TablePageLayout title="Workbench event phases" subtitle="AgentRun、SSE、API、render 分段">
<DataTable :columns="workbenchColumns" :rows="workbenchEventPhases" :row-key="rowKey" empty-text="暂无 Workbench event phase 样本">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).phase || (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).eventType || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).outcome || '-' }}</small></template>
<template #cell-dimensions="{ row }"><span class="performance-dimensions">{{ formatDimensions(row as WebPerformanceRow) }}</span></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p75="{ row }">{{ formatValue(row as WebPerformanceRow, 'p75') }}</template>
<template #cell-p95="{ row }"><strong>{{ formatValue(row as WebPerformanceRow, 'p95') }}</strong><small>{{ (row as WebPerformanceRow).problem || '' }}</small></template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span><small>{{ formatSampleState(row as WebPerformanceRow) }}</small></template>
</DataTable>
</TablePageLayout>
<TablePageLayout class="performance-wide-panel" title="Backend event visible" subtitle="backend event generated 到 Web 可见">
<DataTable :columns="workbenchColumns" :rows="workbenchBackendEvents" :row-key="rowKey" empty-text="暂无 backend event visible 样本">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).eventType || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).outcome || '-' }}</small></template>
<template #cell-dimensions="{ row }"><span class="performance-dimensions">{{ formatDimensions(row as WebPerformanceRow) }}</span></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p75="{ row }">{{ formatValue(row as WebPerformanceRow, 'p75') }}</template>
<template #cell-p95="{ row }"><strong>{{ formatValue(row as WebPerformanceRow, 'p95') }}</strong><small>{{ (row as WebPerformanceRow).problem || '' }}</small></template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span><small>{{ formatSampleState(row as WebPerformanceRow) }}</small></template>
</DataTable>
</TablePageLayout>
</section>
<section class="system-split-grid">
<TablePageLayout title="Web vitals" subtitle="navigation / LCP / CLS / FID">
<DataTable :columns="columns" :rows="webVitals" :row-key="rowKey" empty-text="暂无 Web Vital 样本">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p75="{ row }">{{ formatValue(row as WebPerformanceRow, 'p75') }}</template>
<template #cell-p95="{ row }">{{ formatValue(row as WebPerformanceRow, 'p95') }}</template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span></template>
</DataTable>
</TablePageLayout>
<TablePageLayout title="API timing" subtitle="fetchJson/fetchText 同源 API 耗时">
<DataTable :columns="columns" :rows="apiRoutes" :row-key="rowKey" empty-text="暂无 API timing 样本">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).method || 'GET' }} / {{ (row as WebPerformanceRow).statusClass || '-' }}</small></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p75="{ row }">{{ formatValue(row as WebPerformanceRow, 'p75') }}</template>
<template #cell-p95="{ row }">{{ formatValue(row as WebPerformanceRow, 'p95') }}</template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span></template>
</DataTable>
</TablePageLayout>
</section>
</template>
</section>
</template>
@@ -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();