Merge pull request #1418 from pikasTech/issue-1399-p6-performance-view

feat: add Workbench performance summary view
This commit is contained in:
Lyon
2026-06-17 19:40:17 +08:00
committed by GitHub
5 changed files with 329 additions and 13 deletions
+25
View File
@@ -100,6 +100,31 @@ test("web performance store accepts v2 Workbench journey and phase metrics witho
assert.doesNotMatch(text, /trc_secret|ses_secret|run_secret|cmd_secret|cnv_secret|traceId|sessionId|runId|commandId|conversationId|prompt|api key/iu);
});
test("web performance summary exposes Workbench p75 and low-sample diagnostics without high-cardinality labels", () => {
const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" } });
const result = store.record({
schemaVersion: "hwlab-web-performance-v2",
page: "/workbench/sessions/ses_secret?traceId=trc_secret",
events: [
{ kind: "workbench_journey", journey: "submit_to_first_visible", route: "/workbench/sessions/ses_secret", entry: "new", backend: "agentrun-v01/codex", transport: "sse", visibility: "foreground", outcome: "ok", valueMs: 1200, traceId: "trc_secret" },
{ kind: "workbench_journey", journey: "session_switch_first_visible", route: "/workbench/sessions/ses_secret", source: "rail", targetState: "running", cache: "warm", backend: "agentrun-v01/codex", transport: "rest_gap", visibility: "foreground", outcome: "ok", valueMs: 420, sessionId: "ses_secret" },
{ kind: "workbench_event_phase", phase: "created_to_append", eventType: "backend", backend: "agentrun-v01/codex", transport: "sse", outcome: "ok", valueMs: 28, commandId: "cmd_secret" },
{ kind: "workbench_backend_event_visible", eventType: "terminal", backend: "agentrun-v01/codex", transport: "sse", outcome: "ok", valueMs: 760, runId: "run_secret" }
]
} as Record<string, unknown>);
const summary = store.summary();
const json = JSON.stringify(summary);
assert.deepEqual(result, { accepted: 4, dropped: 0, received: 4 });
assert.equal(summary.summary.sampleCount, 4);
assert.equal(summary.summary.lowSampleThreshold, 5);
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"));
assert.ok(summary.problems.some((row) => row.kind === "workbench_journey" && row.problem === "low-sample"));
assert.doesNotMatch(json, /trc_secret|ses_secret|run_secret|cmd_secret|traceId|sessionId|runId|commandId|conversationId|prompt|api key/iu);
});
test("web performance store drops unsupported Workbench labels and counts series limit drops", () => {
const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" }, maxSeries: 1 });
const first = store.record({
+189 -6
View File
@@ -44,6 +44,7 @@ const WORKBENCH_CACHE_STATES = new Set(["warm", "cold", "unknown"]);
const WORKBENCH_TARGET_STATES = new Set(["running", "terminal", "empty", "unknown"]);
const WORKBENCH_SOURCES = new Set(["rail", "deeplink", "history", "direct", "hydrate", "unknown"]);
const WORKBENCH_AUTH_STATES = new Set(["warm", "login_redirect", "unknown"]);
const LOW_SAMPLE_THRESHOLD = 5;
interface WebPerformanceStoreOptions {
env?: Record<string, unknown>;
@@ -110,10 +111,22 @@ interface PerformanceSummaryRow {
count: number;
average: number;
p50: number;
p75: number;
p95: number;
unit: "seconds" | "score";
tone: "ok" | "warn" | "blocked" | "source";
problem: string;
lowSample?: boolean;
sampleState?: "ok" | "low-sample" | "empty";
backend?: string;
transport?: string;
targetState?: string;
cache?: string;
source?: string;
entry?: string;
authState?: string;
eventType?: string;
phase?: string;
}
export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) {
@@ -294,6 +307,16 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
const durationRows = histogramSummaryRows(durationSeries, DURATION_BUCKETS_SECONDS, "seconds");
const clsRows = histogramSummaryRows(clsSeries, CLS_BUCKETS, "score");
const rows = [...durationRows, ...clsRows];
const workbenchJourneys = workbenchJourneySummaryRows(journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS)
.sort(sortByProblemThenP95)
.slice(0, 24);
const workbenchEventPhases = workbenchEventPhaseSummaryRows(eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS)
.sort(sortByProblemThenP95)
.slice(0, 24);
const workbenchBackendEvents = workbenchBackendEventVisibleSummaryRows(backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS)
.sort(sortByProblemThenP95)
.slice(0, 24);
const workbenchRows = [...workbenchJourneys, ...workbenchEventPhases, ...workbenchBackendEvents];
const apiRoutes = durationRows
.filter((row) => row.metric === "api_request")
.sort(sortByProblemThenP95)
@@ -306,14 +329,15 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
.filter((row) => row.metric === "long_task")
.sort(sortByProblemThenP95)
.slice(0, 12);
const problems = rows
.filter((row) => row.tone === "blocked" || row.tone === "warn" || row.outcome !== "ok")
const problems = [...rows, ...workbenchRows]
.filter((row) => row.tone === "blocked" || row.tone === "warn" || row.outcome !== "ok" || row.lowSample === true)
.sort(sortByProblemThenP95)
.slice(0, 16);
const sampleCount = [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0);
const workbenchSampleCount = [...journeyTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
+ histogramSampleCount(eventPhaseSeries)
+ histogramSampleCount(backendEventVisibleSeries);
const totalSampleCount = sampleCount + workbenchSampleCount;
return {
serviceId: "hwlab-cloud-api",
route: "/v1/web-performance/summary",
@@ -322,7 +346,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
namespace: baseLabels.namespace,
gitopsTarget: baseLabels.gitops_target,
summary: {
sampleCount: sampleCount + workbenchSampleCount,
sampleCount: totalSampleCount,
sampleSeries: sampleSeries.size,
durationSeries: durationSeries.size,
clsSeries: clsSeries.size,
@@ -330,14 +354,19 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
workbenchJourneySeries: journeyDurationSeries.size,
workbenchEventPhaseSeries: eventPhaseSeries.size,
workbenchBackendEventVisibleSeries: backendEventVisibleSeries.size,
routeCount: new Set(rows.map((row) => row.route)).size,
routeCount: new Set([...rows, ...workbenchRows].map((row) => row.route)).size,
problemCount: problems.length,
status: sampleCount === 0 ? "waiting" : sampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok"
status: totalSampleCount === 0 ? "waiting" : totalSampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok",
lowSampleThreshold: LOW_SAMPLE_THRESHOLD
},
apiRoutes,
webVitals,
longTasks,
problems,
workbenchJourneys,
workbenchEventPhases,
workbenchBackendEvents,
workbenchRows: workbenchRows.sort(sortByProblemThenP95).slice(0, 40),
rows: rows.sort(sortByProblemThenP95).slice(0, 40)
};
}
@@ -494,6 +523,7 @@ function histogramSummaryRows(series: Map<string, HistogramSeries>, buckets: num
const count = entry.count;
const average = count > 0 ? entry.sum / count : 0;
const p50 = histogramQuantile(entry, buckets, 0.5);
const p75 = histogramQuantile(entry, buckets, 0.75);
const p95 = histogramQuantile(entry, buckets, 0.95);
const tone = performanceTone(labels, p95, count, unit);
return {
@@ -506,14 +536,131 @@ function histogramSummaryRows(series: Map<string, HistogramSeries>, buckets: num
count,
average: roundMetric(average),
p50: roundMetric(p50),
p75: roundMetric(p75),
p95: roundMetric(p95),
unit,
tone,
problem: performanceProblem(labels, p95, count, unit, tone)
problem: performanceProblem(labels, p95, count, unit, tone),
lowSample: count > 0 && count < LOW_SAMPLE_THRESHOLD,
sampleState: sampleState(count)
};
});
}
function workbenchJourneySummaryRows(series: Map<string, HistogramSeries>, buckets: number[]): PerformanceSummaryRow[] {
return [...series.values()].map((entry) => {
const labels = entry.labels;
const metric = labels.journey;
const row = workbenchSummaryRow(entry, buckets, {
kind: "workbench_journey",
metric,
route: labels.route,
outcome: labels.outcome,
backend: labels.backend,
transport: labels.transport,
targetState: labels.target_state,
cache: labels.cache,
source: labels.source,
entry: labels.entry,
authState: labels.auth_state,
warn: workbenchJourneyThreshold(metric).warn,
blocked: workbenchJourneyThreshold(metric).blocked
});
return row;
});
}
function workbenchEventPhaseSummaryRows(series: Map<string, HistogramSeries>, buckets: number[]): PerformanceSummaryRow[] {
return [...series.values()].map((entry) => {
const labels = entry.labels;
const metric = labels.phase;
const threshold = workbenchEventPhaseThreshold(metric);
return workbenchSummaryRow(entry, buckets, {
kind: "workbench_event_phase",
metric,
route: "workbench-events",
outcome: labels.outcome,
backend: labels.backend,
transport: labels.transport,
eventType: labels.event_type,
phase: labels.phase,
warn: threshold.warn,
blocked: threshold.blocked
});
});
}
function workbenchBackendEventVisibleSummaryRows(series: Map<string, HistogramSeries>, buckets: number[]): PerformanceSummaryRow[] {
return [...series.values()].map((entry) => {
const labels = entry.labels;
const threshold = workbenchJourneyThreshold("backend_event_to_visible");
return workbenchSummaryRow(entry, buckets, {
kind: "workbench_backend_event_visible",
metric: "backend_event_to_visible",
route: "workbench-events",
outcome: labels.outcome,
backend: labels.backend,
transport: labels.transport,
eventType: labels.event_type,
warn: threshold.warn,
blocked: threshold.blocked
});
});
}
function workbenchSummaryRow(entry: HistogramSeries, buckets: number[], input: {
kind: string;
metric: string;
route: string;
outcome: string;
warn: number;
blocked: number;
backend?: string;
transport?: string;
targetState?: string;
cache?: string;
source?: string;
entry?: string;
authState?: string;
eventType?: string;
phase?: string;
}): PerformanceSummaryRow {
const count = entry.count;
const average = count > 0 ? entry.sum / count : 0;
const p50 = histogramQuantile(entry, buckets, 0.5);
const p75 = histogramQuantile(entry, buckets, 0.75);
const p95 = histogramQuantile(entry, buckets, 0.95);
const lowSample = count > 0 && count < LOW_SAMPLE_THRESHOLD;
const tone = workbenchTone(input.outcome, p95, count, input.warn, input.blocked);
return {
kind: input.kind,
metric: input.metric,
route: input.route,
method: "-",
statusClass: "-",
outcome: input.outcome,
count,
average: roundMetric(average),
p50: roundMetric(p50),
p75: roundMetric(p75),
p95: roundMetric(p95),
unit: "seconds",
tone,
problem: workbenchProblem(input.outcome, input.metric, count, tone),
lowSample,
sampleState: sampleState(count),
backend: input.backend,
transport: input.transport,
targetState: input.targetState,
cache: input.cache,
source: input.source,
entry: input.entry,
authState: input.authState,
eventType: input.eventType,
phase: input.phase
};
}
function histogramQuantile(entry: HistogramSeries, buckets: number[], quantile: number) {
if (entry.count <= 0) return 0;
const rank = Math.max(1, Math.ceil(entry.count * quantile));
@@ -543,6 +690,29 @@ function performanceTone(labels: Record<string, string>, p95: number, count: num
return p95 > 1 ? "warn" : "ok";
}
function workbenchTone(outcome: string, p95: number, count: number, warn: number, blocked: number) {
if (outcome !== "ok") return "blocked";
if (count <= 0 || count < LOW_SAMPLE_THRESHOLD) return "source";
return thresholdTone(p95, warn, blocked);
}
function workbenchJourneyThreshold(metric: string) {
if (metric === "session_switch_first_visible") return { warn: 1, blocked: 3 };
if (metric === "session_switch_full_load") return { warn: 2.5, blocked: 8 };
if (metric === "workbench_open_first_visible") return { warn: 2.5, blocked: 5 };
if (metric === "workbench_open_full_load") return { warn: 5, blocked: 13 };
if (metric === "backend_event_to_visible") return { warn: 3, blocked: 10 };
return { warn: 5, blocked: 15 };
}
function workbenchEventPhaseThreshold(metric: string) {
if (metric === "append_to_sse") return { warn: 0.25, blocked: 1 };
if (metric === "created_to_append" || metric === "receive_to_project" || metric === "project_to_paint") return { warn: 0.5, blocked: 2 };
if (metric === "sse_to_receive") return { warn: 1, blocked: 3 };
if (metric === "user_submit_to_api_accepted") return { warn: 1, blocked: 3 };
return { warn: 5, blocked: 15 };
}
function thresholdTone(value: number, warn: number, blocked: number) {
if (value >= blocked) return "blocked";
if (value >= warn) return "warn";
@@ -559,6 +729,19 @@ function performanceProblem(labels: Record<string, string>, p95: number, count:
return "ok";
}
function workbenchProblem(outcome: string, metric: string, count: number, tone: string) {
if (count <= 0) return "no-sample";
if (count < LOW_SAMPLE_THRESHOLD) return "low-sample";
if (outcome !== "ok") return outcome;
if (tone === "blocked" || tone === "warn") return `slow-${metric}`;
return "ok";
}
function sampleState(count: number): "ok" | "low-sample" | "empty" {
if (count <= 0) return "empty";
return count < LOW_SAMPLE_THRESHOLD ? "low-sample" : "ok";
}
function sortByProblemThenP95(left: PerformanceSummaryRow, right: PerformanceSummaryRow) {
return toneWeight(right.tone) - toneWeight(left.tone) || right.p95 - left.p95 || right.count - left.count;
}
@@ -1,3 +1,4 @@
/* SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. */
.platform-shell {
display: grid;
height: 100dvh;
@@ -2304,6 +2305,20 @@
gap: 14px;
}
.performance-wide-panel {
grid-column: 1 / -1;
}
.performance-dimensions {
display: block;
max-width: 360px;
min-width: 0;
overflow-wrap: anywhere;
color: #334155;
font-size: 12px;
line-height: 1.4;
}
.skills-tree-panel,
.skills-preview-panel {
display: grid;
+18 -2
View File
@@ -1,4 +1,4 @@
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0.
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Cloud Web API and Workbench state type contracts shared by stores and components.
export type Tone = "ok" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run";
@@ -296,10 +296,22 @@ export interface WebPerformanceRow extends Record<string, unknown> {
count?: number;
average?: number;
p50?: number;
p75?: number;
p95?: number;
unit?: "seconds" | "score" | string;
tone?: "ok" | "warn" | "blocked" | "pending" | string;
problem?: string;
lowSample?: boolean;
sampleState?: "ok" | "low-sample" | "empty" | string;
backend?: string;
transport?: string;
targetState?: string;
cache?: string;
source?: string;
entry?: string;
authState?: string;
eventType?: string;
phase?: string;
}
export interface WebPerformanceSummaryResponse {
@@ -308,11 +320,15 @@ export interface WebPerformanceSummaryResponse {
observedAt?: string;
namespace?: string;
gitopsTarget?: string;
summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; [key: string]: unknown };
summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; lowSampleThreshold?: number; [key: string]: unknown };
apiRoutes?: WebPerformanceRow[];
webVitals?: WebPerformanceRow[];
longTasks?: WebPerformanceRow[];
problems?: WebPerformanceRow[];
workbenchJourneys?: WebPerformanceRow[];
workbenchEventPhases?: WebPerformanceRow[];
workbenchBackendEvents?: WebPerformanceRow[];
workbenchRows?: WebPerformanceRow[];
rows?: WebPerformanceRow[];
[key: string]: unknown;
}
@@ -1,3 +1,4 @@
<!-- SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. -->
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { systemAPI } from "@/api";
@@ -15,6 +16,18 @@ const columns: DataTableColumn[] = [
{ key: "route", label: "Route" },
{ 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: "状态" }
];
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: "状态" }
];
@@ -32,7 +45,12 @@ 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 },
@@ -43,10 +61,10 @@ const maxChartValue = computed(() => Math.max(1, ...chartRows.value.map((row) =>
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'}`;
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'}`;
}
function formatValue(row: WebPerformanceRow, key: "average" | "p50" | "p95"): string {
function formatValue(row: WebPerformanceRow, key: "average" | "p50" | "p75" | "p95"): string {
const value = Number(row[key]);
if (!Number.isFinite(value)) return "-";
if (row.unit === "score") return value.toFixed(3);
@@ -56,11 +74,29 @@ function formatValue(row: WebPerformanceRow, key: "average" | "p50" | "p95"): st
function formatCount(value?: number): string {
return Number.isFinite(Number(value)) ? String(value) : "0";
}
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}` : ""
].filter(Boolean);
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";
}
</script>
<template>
<section class="route-stack system-page performance-page">
<PageHeader eyebrow="System" title="性能监控" description="RUM 已在 Vue 入口安装,页面汇总真实浏览器 navigation/Web Vital/API timing/long task 样本。" />
<PageHeader eyebrow="System" title="性能监控" description="RUM 已在 Vue 入口安装,页面汇总真实浏览器 Workbench、navigationWeb VitalAPI timinglong task 样本。" />
<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" />
@@ -69,7 +105,7 @@ function formatCount(value?: number): string {
<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>Routes</span><strong>{{ formatCount(summary.routeCount as number) }}</strong><small>{{ payload?.namespace || '-' }} / {{ payload?.gitopsTarget || '-' }}</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>
<section class="data-panel rum-chart-panel" aria-label="Performance chart">
@@ -86,10 +122,49 @@ function formatCount(value?: number): string {
<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>
<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></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 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 样本">
@@ -97,6 +172,7 @@ function formatCount(value?: number): string {
<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>
@@ -107,6 +183,7 @@ function formatCount(value?: number): string {
<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>