Merge pull request #1418 from pikasTech/issue-1399-p6-performance-view
feat: add Workbench performance summary view
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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、navigation、Web Vital、API timing 和 long 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>
|
||||
|
||||
Reference in New Issue
Block a user