Merge pull request #1611 from pikasTech/feat/1609-performance-dashboard
feat: add workbench performance dashboard
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user