From 206d0306a69a28309df4cb6e85b28e758dd37298 Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 17 Jun 2026 19:39:20 +0800 Subject: [PATCH] feat: add workbench performance summary view --- internal/cloud/web-performance.test.ts | 25 +++ internal/cloud/web-performance.ts | 195 +++++++++++++++++- web/hwlab-cloud-web/src/styles/workbench.css | 15 ++ web/hwlab-cloud-web/src/types/index.ts | 20 +- .../src/views/PerformanceView.vue | 87 +++++++- 5 files changed, 329 insertions(+), 13 deletions(-) diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index 58edc90d..fe5704ae 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -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); + + 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({ diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index 68ad7635..66e2689f 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -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; @@ -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, 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, 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, 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, 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, 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, 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, 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; } diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 2a3f87b8..63f0028e 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 429e148d..0ed8d69a 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -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 { 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; } diff --git a/web/hwlab-cloud-web/src/views/PerformanceView.vue b/web/hwlab-cloud-web/src/views/PerformanceView.vue index c7ad6ff3..a444b7db 100644 --- a/web/hwlab-cloud-web/src/views/PerformanceView.vue +++ b/web/hwlab-cloud-web/src/views/PerformanceView.vue @@ -1,3 +1,4 @@ +