diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index f6c9f86d..c90c0408 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -44,7 +44,12 @@ 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.collectionHealth.schemaVersion, "hwlab-web-performance-collection-health-v1"); + assert.equal(summary.collectionHealth.status, "low-sample"); + assert.equal(summary.collectionHealth.reason, "low_sample_window"); + assert.ok(summary.collectionHealth.diagnostics.some((item) => item.code === "low_sample_window")); assert.equal(summary.dashboard.schemaVersion, "hwlab-web-performance-dashboard-v1"); + assert.equal(summary.dashboard.collectionHealth.status, "low-sample"); 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)); @@ -55,6 +60,36 @@ test("web performance summary exposes user-perceived route latency without high- assert.doesNotMatch(json, /trc_secret|sessionId|conversationId|threadId|prompt|api key/iu); }); +test("web performance summary explains empty recent windows when retained samples exist", () => { + let nowMs = 0; + const store = createWebPerformanceStore({ + env: { HWLAB_METRICS_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" }, + now: () => nowMs + }); + + nowMs = 1_000; + store.record({ + schemaVersion: "hwlab-web-performance-v1", + page: "/workbench", + events: [{ kind: "api", metric: "api_request", route: "/v1/workbench/sessions", method: "GET", status: 200, statusClass: "2xx", outcome: "ok", valueMs: 450, traceId: "trc_old_secret" }] + } as Record); + + nowMs = 20 * 60 * 1000; + const recent = store.summary({ window: "5m" }); + const json = JSON.stringify(recent); + + assert.equal(recent.summary.sampleCount, 0); + assert.equal(recent.collectionHealth.status, "stale"); + assert.equal(recent.collectionHealth.reason, "no_samples_in_window"); + assert.equal(recent.collectionHealth.retainedSampleCount, 1); + assert.equal(recent.collectionHealth.lastSampleAt, null); + assert.ok(recent.collectionHealth.lastRetainedSampleAt); + assert.ok(recent.collectionHealth.diagnostics.some((item) => item.code === "no_samples_in_window")); + assert.equal(recent.dashboard.freshness.status, "stale"); + assert.equal(recent.dashboard.collectionHealth.reason, "no_samples_in_window"); + assert.doesNotMatch(json, /trc_old_secret|traceId|prompt|api key/iu); +}); + test("web performance summary filters rows by requested recent window", () => { let nowMs = 0; const store = createWebPerformanceStore({ @@ -175,6 +210,8 @@ 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.collectionHealth.status, "low-sample"); + assert.ok(summary.collectionHealth.sourceFamilyCounts.some((item) => item.sourceFamily === "workbench_journey")); 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 === "低样本"))); diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index 0e391489..43745168 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -218,6 +218,39 @@ interface PerformanceDashboardTopRow { tone: string; } +interface PerformanceCollectionHealthDiagnostic { + code: string; + label: string; + tone: "ok" | "warn" | "blocked" | "source"; + detail: string; +} + +interface PerformanceCollectionHealth { + schemaVersion: "hwlab-web-performance-collection-health-v1"; + status: "fresh" | "low-sample" | "stale" | "degraded" | "waiting"; + statusLabel: string; + reason: string; + reasonLabel: string; + sampleCount: number; + retainedSampleCount: number; + sampleSeries: number; + lowSampleThreshold: number; + firstSampleAt: string | null; + lastSampleAt: string | null; + lastRetainedSampleAt: string | null; + secondsSinceLastSample: number | null; + windowCoverageSeconds: number | null; + sourceFamilyCounts: Array<{ sourceFamily: string; rowCount: number; sampleCount: number }>; + unknownAttribution: { + workbenchRows: number; + rowsWithUnknown: number; + samplesWithUnknown: number; + backendUnknownRows: number; + transportUnknownRows: number; + }; + diagnostics: PerformanceCollectionHealthDiagnostic[]; +} + export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) { const env = options.env ?? process.env; const maxSeries = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 240); @@ -444,7 +477,8 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = const observedAt = new Date(generatedAtMs).toISOString(); const windowBounds = performanceSummaryWindowBounds(sampleWindow, generatedAtMs, observedAt); const aggregate = aggregateForWindow(sampleWindow, generatedAtMs); - const exactStats = exactStatsForSamples(samplesForWindow(sampleWindow, generatedAtMs, samples)); + const windowSamples = samplesForWindow(sampleWindow, generatedAtMs, samples); + const exactStats = exactStatsForSamples(windowSamples); const durationRows = applySummaryContract(histogramSummaryRows(aggregate.durationSeries, DURATION_BUCKETS_SECONDS, "seconds"), exactStats, windowBounds); const clsRows = applySummaryContract(histogramSummaryRows(aggregate.clsSeries, CLS_BUCKETS, "score"), exactStats, windowBounds); const rows = [...durationRows, ...clsRows]; @@ -503,6 +537,15 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = aggregationKind: "exact", approximation: "exact" }; + const collectionHealth = buildPerformanceCollectionHealth({ + observedAt, + generatedAtMs, + sampleWindow, + summary: summaryInfo, + windowSamples, + retainedSamples: samples, + rows: [...rows, ...workbenchRows] + }); const dashboard = buildPerformanceDashboard({ observedAt, source: "cloud-api-rum-store", @@ -510,6 +553,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = gitopsTarget: baseLabels.gitops_target, sampleWindow, summary: summaryInfo, + collectionHealth, apiRoutes, webVitals, longTasks, @@ -538,6 +582,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = approximation: "exact" }, summary: summaryInfo, + collectionHealth, dashboard, apiRoutes, webVitals, @@ -1268,6 +1313,137 @@ function toneWeight(tone: string) { return 1; } +function buildPerformanceCollectionHealth(input: { + observedAt: string; + generatedAtMs: number; + sampleWindow: PerformanceSummaryWindow; + summary: Record; + windowSamples: RecordedPerformanceSample[]; + retainedSamples: RecordedPerformanceSample[]; + rows: PerformanceSummaryRow[]; +}): PerformanceCollectionHealth { + const sampleCount = numberField(input.summary.sampleCount); + const sampleSeries = numberField(input.summary.sampleSeries) + + numberField(input.summary.workbenchJourneySeries) + + numberField(input.summary.workbenchEventPhaseSeries) + + numberField(input.summary.workbenchBackendEventVisibleSeries) + + numberField(input.summary.workbenchUiEventSeries); + const firstSampleMs = minSampleAt(input.windowSamples); + const lastSampleMs = maxSampleAt(input.windowSamples); + const lastRetainedSampleMs = maxSampleAt(input.retainedSamples); + const sourceFamilyCounts = collectionSourceFamilyCounts(input.rows); + const unknownAttribution = collectionUnknownAttribution(input.rows); + const diagnostics: PerformanceCollectionHealthDiagnostic[] = []; + let status: PerformanceCollectionHealth["status"] = "fresh"; + let reason = "window_has_samples"; + let reasonLabel = "当前窗口采集正常"; + + if (input.retainedSamples.length <= 0) { + status = "waiting"; + reason = "no_samples_retained"; + reasonLabel = "采集器尚未上报样本"; + diagnostics.push({ code: reason, label: "暂无采集样本", tone: "source", detail: "Cloud API RUM store 当前没有保留任何浏览器性能样本。" }); + } else if (sampleCount <= 0) { + status = "stale"; + reason = "no_samples_in_window"; + reasonLabel = `${input.sampleWindow.label}无样本`; + diagnostics.push({ code: reason, label: "当前窗口无样本", tone: "source", detail: "存在历史保留样本,但当前所选时间窗口没有样本;请切换更大窗口或等待新上报。" }); + } else if (sampleCount < LOW_SAMPLE_THRESHOLD) { + status = "low-sample"; + reason = "low_sample_window"; + reasonLabel = "当前窗口低样本"; + diagnostics.push({ code: reason, label: "低样本窗口", tone: "warn", detail: `当前窗口只有 ${sampleCount} 个样本,低于 ${LOW_SAMPLE_THRESHOLD} 个样本阈值。` }); + } + + if (unknownAttribution.rowsWithUnknown > 0) { + if (status === "fresh") { + status = "degraded"; + reason = "unknown_attribution"; + reasonLabel = "归因字段不完整"; + } + diagnostics.push({ + code: "unknown_attribution", + label: "工作台归因不完整", + tone: "warn", + detail: `${unknownAttribution.rowsWithUnknown} 条工作台序列存在 backend 或 transport unknown,影响慢路径归因。` + }); + } + + if (sourceFamilyCounts.length > 1) { + diagnostics.push({ code: "mixed_source_family", label: "多来源样本", tone: "ok", detail: `当前窗口包含 ${sourceFamilyCounts.length} 类低基数来源。` }); + } + + if (!diagnostics.length) diagnostics.push({ code: "ok", label: "采集健康", tone: "ok", detail: "当前窗口有足够样本,且没有发现低样本、缺样或 unknown 归因问题。" }); + + return { + schemaVersion: "hwlab-web-performance-collection-health-v1", + status, + statusLabel: collectionStatusLabel(status), + reason, + reasonLabel, + sampleCount, + retainedSampleCount: input.retainedSamples.length, + sampleSeries, + lowSampleThreshold: LOW_SAMPLE_THRESHOLD, + firstSampleAt: isoFromMs(firstSampleMs), + lastSampleAt: isoFromMs(lastSampleMs), + lastRetainedSampleAt: isoFromMs(lastRetainedSampleMs), + secondsSinceLastSample: lastRetainedSampleMs === null ? null : Math.max(0, Math.round((input.generatedAtMs - lastRetainedSampleMs) / 1000)), + windowCoverageSeconds: firstSampleMs === null || lastSampleMs === null ? null : Math.max(0, Math.round((lastSampleMs - firstSampleMs) / 1000)), + sourceFamilyCounts, + unknownAttribution, + diagnostics + }; +} + +function collectionSourceFamilyCounts(rows: PerformanceSummaryRow[]) { + const counts = new Map(); + for (const row of rows) { + const sourceFamily = String(row.sourceFamily ?? sourceFamilyForRow(row) ?? "unknown"); + const entry = counts.get(sourceFamily) ?? { sourceFamily, rowCount: 0, sampleCount: 0 }; + entry.rowCount += 1; + entry.sampleCount += Math.max(0, Number(row.count) || 0); + counts.set(sourceFamily, entry); + } + return [...counts.values()].sort((left, right) => right.sampleCount - left.sampleCount || left.sourceFamily.localeCompare(right.sourceFamily)); +} + +function collectionUnknownAttribution(rows: PerformanceSummaryRow[]) { + const workbenchRows = rows.filter((row) => row.kind === "workbench_journey" || row.kind === "workbench_event_phase" || row.kind === "workbench_backend_event_visible"); + const backendUnknownRows = workbenchRows.filter((row) => unknownAttributionValue(row.backend)).length; + const transportUnknownRows = workbenchRows.filter((row) => unknownAttributionValue(row.transport)).length; + const rowsWithUnknown = workbenchRows.filter((row) => unknownAttributionValue(row.backend) || unknownAttributionValue(row.transport)).length; + const samplesWithUnknown = workbenchRows.reduce((sum, row) => sum + ((unknownAttributionValue(row.backend) || unknownAttributionValue(row.transport)) ? Math.max(0, Number(row.count) || 0) : 0), 0); + return { workbenchRows: workbenchRows.length, rowsWithUnknown, samplesWithUnknown, backendUnknownRows, transportUnknownRows }; +} + +function unknownAttributionValue(value: unknown) { + const text = String(value ?? "").trim().toLowerCase(); + return !text || text === "-" || text === "unknown"; +} + +function minSampleAt(samples: RecordedPerformanceSample[]) { + const values = samples.map((sample) => sample.atMs).filter(Number.isFinite); + return values.length ? Math.min(...values) : null; +} + +function maxSampleAt(samples: RecordedPerformanceSample[]) { + const values = samples.map((sample) => sample.atMs).filter(Number.isFinite); + return values.length ? Math.max(...values) : null; +} + +function isoFromMs(value: number | null) { + return value === null ? null : new Date(value).toISOString(); +} + +function collectionStatusLabel(status: PerformanceCollectionHealth["status"]) { + if (status === "fresh") return "采集正常"; + if (status === "low-sample") return "低样本"; + if (status === "stale") return "当前窗口无样本"; + if (status === "degraded") return "归因不完整"; + return "等待数据"; +} + function buildPerformanceDashboard(input: { observedAt: string; source: string; @@ -1275,6 +1451,7 @@ function buildPerformanceDashboard(input: { gitopsTarget: string; sampleWindow: PerformanceSummaryWindow; summary: Record; + collectionHealth: PerformanceCollectionHealth; apiRoutes: PerformanceSummaryRow[]; webVitals: PerformanceSummaryRow[]; longTasks: PerformanceSummaryRow[]; @@ -1289,6 +1466,7 @@ function buildPerformanceDashboard(input: { const sampleCount = numberField(input.summary.sampleCount); const problemCount = numberField(input.summary.problemCount); const status = String(input.summary.status ?? "waiting"); + const collectionHealth = input.collectionHealth; const trends = [ dashboardTrend("workbench-experience", "工作台体感趋势", "首屏、会话切换和提交首个可见结果的 P95", "seconds", input.workbenchJourneys), dashboardTrend("backend-visible", "后端事件可见延迟", "AgentRun/backend 事件到浏览器可见的 P95", "seconds", input.workbenchBackendEvents), @@ -1314,17 +1492,23 @@ function buildPerformanceDashboard(input: { source: input.source, namespace: input.namespace, gitopsTarget: input.gitopsTarget, - status, - statusLabel: dashboardStatusLabel(status), + status: collectionHealth.status, + statusLabel: collectionHealth.statusLabel, + reason: collectionHealth.reason, + reasonLabel: collectionHealth.reasonLabel, lowSampleThreshold: numberField(input.summary.lowSampleThreshold), generatedAt: input.summary.generatedAt ?? input.observedAt, windowFrom: input.summary.windowFrom ?? null, windowTo: input.summary.windowTo ?? input.observedAt, sampleCount, - sourceFamily: "mixed" + sourceFamily: "mixed", + lastSampleAt: collectionHealth.lastSampleAt, + lastRetainedSampleAt: collectionHealth.lastRetainedSampleAt, + secondsSinceLastSample: collectionHealth.secondsSinceLastSample }, + collectionHealth, cards: [ - { id: "health", label: "采集状态", value: dashboardStatusLabel(status), unit: "状态", tone: dashboardTone(status), detail: input.source }, + { id: "health", label: "采集状态", value: collectionHealth.statusLabel, unit: "状态", tone: dashboardTone(collectionHealth.status), detail: collectionHealth.reasonLabel }, { id: "samples", label: "样本数", value: sampleCount, unit: "样本", tone: sampleCount === 0 ? "source" : "ok", detail: `${input.sampleWindow.label} · ${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)} 个样本` } @@ -1464,6 +1648,10 @@ function sampleStateLabel(state: string | undefined) { } function dashboardStatusLabel(status: string) { + if (status === "fresh") return "采集正常"; + if (status === "low-sample") return "低样本"; + if (status === "stale") return "当前窗口无样本"; + if (status === "degraded") return "归因不完整"; if (status === "ok") return "正常"; if (status === "watch") return "预警"; if (status === "warming") return "低样本"; @@ -1471,8 +1659,8 @@ function dashboardStatusLabel(status: string) { } function dashboardTone(status: string) { - if (status === "ok") return "ok"; - if (status === "watch") return "warn"; + if (status === "ok" || status === "fresh") return "ok"; + if (status === "watch" || status === "degraded" || status === "low-sample") return "warn"; return "source"; } diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index f5372949..3559b44b 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -285,6 +285,7 @@ function webPerformanceSummaryPayload(url: URL): JsonRecord { performanceRow({ kind: "long-task", metric: "longtask", route: "/workbench", count: 3, p50: 0.08, p75: 0.11, p95: 0.2, tone: "warn", problem: "main-thread busy", sourceFamily: "web" }, sampleWindow) ]; const sampleCount = performanceSampleCount(rows); + const collectionHealth = performanceCollectionHealthPayload(rows, sampleWindow); return { ok: true, status: "ready", @@ -294,7 +295,8 @@ function webPerformanceSummaryPayload(url: URL): JsonRecord { gitopsTarget: "D601/v03", sampleWindow: performanceSampleWindowPayload(sampleWindow), summary: performanceSummaryContract(sampleWindow, rows, { status: "ready", sampleCount, sampleSeries: 12, problemCount: rows.filter((row) => row.tone === "warn").length }), - dashboard: webPerformanceDashboardPayload(rows, sampleWindow), + collectionHealth, + dashboard: webPerformanceDashboardPayload(rows, sampleWindow, collectionHealth), rows, problems: rows.filter((row) => row.tone === "warn"), webVitals: rows.filter((row) => row.kind === "web-vital"), @@ -309,6 +311,7 @@ function webPerformanceSummaryPayload(url: URL): JsonRecord { function webPerformanceEmptyPayload(url: URL): JsonRecord { const sampleWindow = fakePerformanceWindow(url.searchParams.get("window")); const rows: JsonRecord[] = []; + const collectionHealth = performanceCollectionHealthPayload(rows, sampleWindow); return { ok: true, status: "waiting", @@ -318,7 +321,8 @@ function webPerformanceEmptyPayload(url: URL): JsonRecord { gitopsTarget: "D601/v03", sampleWindow: performanceSampleWindowPayload(sampleWindow), summary: performanceSummaryContract(sampleWindow, rows, { status: "waiting", sampleCount: 0, sampleSeries: 0, problemCount: 0 }), - dashboard: webPerformanceDashboardPayload(rows, sampleWindow), + collectionHealth, + dashboard: webPerformanceDashboardPayload(rows, sampleWindow, collectionHealth), rows, problems: [], webVitals: [], @@ -351,7 +355,7 @@ function performanceRow(row: JsonRecord, sampleWindow: FakePerformanceWindow): J }; } -function webPerformanceDashboardPayload(rows: JsonRecord[], sampleWindow: FakePerformanceWindow): JsonRecord { +function webPerformanceDashboardPayload(rows: JsonRecord[], sampleWindow: FakePerformanceWindow, collectionHealth: JsonRecord = performanceCollectionHealthPayload(rows, sampleWindow)): 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"); @@ -362,9 +366,10 @@ function webPerformanceDashboardPayload(rows: JsonRecord[], sampleWindow: FakePe schemaVersion: "hwlab-web-performance-dashboard-v1", generatedAt: sampleWindow.generatedAt, sampleWindow: { ...performanceSampleWindowPayload(sampleWindow), buckets: [{ id: sampleWindow.id, label: sampleWindow.label, observedAt: sampleWindow.generatedAt, sampleCount }] }, - freshness: { observedAt: sampleWindow.generatedAt, generatedAt: sampleWindow.generatedAt, windowFrom: sampleWindow.windowFrom, windowTo: sampleWindow.windowTo, sampleCount, source: "fake-server", sourceFamily: "mixed", namespace: "hwlab-v03", gitopsTarget: "D601/v03", status: sampleCount === 0 ? "waiting" : "watch", statusLabel: sampleCount === 0 ? "等待数据" : "预警", lowSampleThreshold: 5 }, + freshness: { observedAt: sampleWindow.generatedAt, generatedAt: sampleWindow.generatedAt, windowFrom: sampleWindow.windowFrom, windowTo: sampleWindow.windowTo, sampleCount, source: "fake-server", sourceFamily: "mixed", namespace: "hwlab-v03", gitopsTarget: "D601/v03", status: collectionHealth.status, statusLabel: collectionHealth.statusLabel, reason: collectionHealth.reason, reasonLabel: collectionHealth.reasonLabel, lowSampleThreshold: 5, lastSampleAt: collectionHealth.lastSampleAt, lastRetainedSampleAt: collectionHealth.lastRetainedSampleAt, secondsSinceLastSample: collectionHealth.secondsSinceLastSample }, + collectionHealth, cards: [ - { id: "health", label: "采集状态", value: sampleCount === 0 ? "等待数据" : "预警", unit: "状态", tone: sampleCount === 0 ? "source" : "warn", detail: "fake-server" }, + { id: "health", label: "采集状态", value: collectionHealth.statusLabel, unit: "状态", tone: sampleCount === 0 ? "source" : collectionHealth.status === "fresh" ? "ok" : "warn", detail: collectionHealth.reasonLabel }, { id: "samples", label: "样本数", value: sampleCount, unit: "样本", tone: sampleCount === 0 ? "source" : "ok", detail: `${sampleWindow.label} · ${rows.length} 个序列` }, { id: "workbench", label: "工作台序列", value: workbench.length, unit: "序列", tone: workbench.length > 0 ? "ok" : "source", detail: "hwlab-v03 / D601/v03" }, { id: "problems", label: "问题行", value: problemCount, unit: "条", tone: problemCount > 0 ? "warn" : "ok", detail: "低样本阈值 5 个样本" } @@ -386,6 +391,86 @@ function webPerformanceDashboardPayload(rows: JsonRecord[], sampleWindow: FakePe }; } +function performanceCollectionHealthPayload(rows: JsonRecord[], sampleWindow: FakePerformanceWindow): JsonRecord { + const sampleCount = performanceSampleCount(rows); + const sourceFamilyCounts = performanceSourceFamilyCounts(rows); + const unknownAttribution = performanceUnknownAttribution(rows); + const diagnostics: JsonRecord[] = []; + let status = "fresh"; + let reason = "window_has_samples"; + let reasonLabel = "当前窗口采集正常"; + + if (sampleCount <= 0) { + status = "waiting"; + reason = "no_samples_retained"; + reasonLabel = "采集器尚未上报样本"; + diagnostics.push({ code: reason, label: "暂无采集样本", tone: "source", detail: "fake-server 当前场景没有性能样本。" }); + } else if (sampleCount < 5) { + status = "low-sample"; + reason = "low_sample_window"; + reasonLabel = "当前窗口低样本"; + diagnostics.push({ code: reason, label: "低样本窗口", tone: "warn", detail: `当前窗口只有 ${sampleCount} 个样本,低于 5 个样本阈值。` }); + } + + const rowsWithUnknown = Number(unknownAttribution.rowsWithUnknown ?? 0); + if (rowsWithUnknown > 0) { + if (status === "fresh") { + status = "degraded"; + reason = "unknown_attribution"; + reasonLabel = "归因字段不完整"; + } + diagnostics.push({ code: "unknown_attribution", label: "工作台归因不完整", tone: "warn", detail: `${rowsWithUnknown} 条工作台序列存在 backend 或 transport unknown。` }); + } + + if (sourceFamilyCounts.length > 1) diagnostics.push({ code: "mixed_source_family", label: "多来源样本", tone: "ok", detail: `当前窗口包含 ${sourceFamilyCounts.length} 类低基数来源。` }); + if (!diagnostics.length) diagnostics.push({ code: "ok", label: "采集健康", tone: "ok", detail: "当前窗口有足够样本,且没有发现低样本、缺样或 unknown 归因问题。" }); + + return { + schemaVersion: "hwlab-web-performance-collection-health-v1", + status, + statusLabel: status === "fresh" ? "采集正常" : status === "low-sample" ? "低样本" : status === "degraded" ? "归因不完整" : "等待数据", + reason, + reasonLabel, + sampleCount, + retainedSampleCount: sampleCount, + sampleSeries: rows.length, + lowSampleThreshold: 5, + firstSampleAt: sampleCount > 0 ? sampleWindow.windowFrom : null, + lastSampleAt: sampleCount > 0 ? sampleWindow.generatedAt : null, + lastRetainedSampleAt: sampleCount > 0 ? sampleWindow.generatedAt : null, + secondsSinceLastSample: sampleCount > 0 ? 0 : null, + windowCoverageSeconds: sampleCount > 0 ? sampleWindow.seconds : null, + sourceFamilyCounts, + unknownAttribution, + diagnostics + }; +} + +function performanceSourceFamilyCounts(rows: JsonRecord[]): JsonRecord[] { + const counts = new Map(); + for (const row of rows) { + const sourceFamily = String(row.sourceFamily ?? "mixed"); + const entry = counts.get(sourceFamily) ?? { sourceFamily, rowCount: 0, sampleCount: 0 }; + entry.rowCount += 1; + entry.sampleCount += Math.max(0, Number(row.count) || 0); + counts.set(sourceFamily, entry); + } + return [...counts.values()].sort((left, right) => right.sampleCount - left.sampleCount || left.sourceFamily.localeCompare(right.sourceFamily)); +} + +function performanceUnknownAttribution(rows: JsonRecord[]): JsonRecord { + const workbenchRows = rows.filter((row) => String(row.kind ?? "").startsWith("workbench")); + const isUnknown = (value: unknown) => !String(value ?? "").trim() || String(value ?? "").trim().toLowerCase() === "unknown" || String(value ?? "").trim() === "-"; + const rowsWithUnknown = workbenchRows.filter((row) => isUnknown(row.backend) || isUnknown(row.transport)).length; + return { + workbenchRows: workbenchRows.length, + rowsWithUnknown, + samplesWithUnknown: workbenchRows.reduce((sum, row) => sum + ((isUnknown(row.backend) || isUnknown(row.transport)) ? Math.max(0, Number(row.count) || 0) : 0), 0), + backendUnknownRows: workbenchRows.filter((row) => isUnknown(row.backend)).length, + transportUnknownRows: workbenchRows.filter((row) => isUnknown(row.transport)).length + }; +} + function fakePerformanceWindow(value: string | null): FakePerformanceWindow { const id = ["5m", "15m", "1h", "6h", "24h", "all"].includes(String(value)) ? String(value) : "15m"; const generatedAt = "2026-06-18T04:30:00.000Z"; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index a1e0a987..4133182b 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -2566,6 +2566,76 @@ font-size: 12px; } +.performance-collection-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.performance-collection-item { + display: grid; + min-width: 0; + gap: 4px; + border: 1px solid #dbe3ef; + border-left: 3px solid #475569; + border-radius: 8px; + background: #ffffff; + padding: 10px; +} + +.performance-collection-item span, +.performance-collection-item small, +.performance-collection-diagnostics small { + min-width: 0; + overflow: hidden; + color: #64748b; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-collection-item strong, +.performance-collection-diagnostics strong { + min-width: 0; + overflow: hidden; + color: #0f172a; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-collection-diagnostics { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.performance-collection-diagnostics li { + display: grid; + grid-template-columns: minmax(120px, 0.28fr) minmax(0, 1fr); + gap: 10px; + align-items: center; + border: 1px solid #dbe3ef; + border-left: 3px solid #64748b; + border-radius: 8px; + background: #f8fafc; + padding: 9px 10px; +} + +.performance-collection-diagnostics li.is-ok { + border-left-color: #0f766e; +} + +.performance-collection-diagnostics li.is-warn { + border-left-color: #b45309; +} + +.performance-collection-diagnostics li.is-blocked { + border-left-color: #b91c1c; +} + .performance-diagnostic-panel { padding: 18px; } @@ -2940,6 +3010,7 @@ .system-split-grid, .performance-health-strip, .performance-contract-strip, + .performance-collection-strip, .performance-chart-grid, .performance-analytics-grid { grid-template-columns: 1fr; @@ -3102,6 +3173,7 @@ .system-filter-bar, .rum-bar-row, .performance-freshness-panel, + .performance-collection-diagnostics li, .performance-bar-row, .performance-top-row, .skill-tree-list li { @@ -3119,6 +3191,7 @@ .performance-chart-card header, .performance-freshness-panel, + .performance-collection-diagnostics li, .performance-top-row { gap: 8px; } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 445545f3..b9cb4bfd 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -429,11 +429,39 @@ export interface WebPerformanceDashboardCard extends Record { detail?: string; } +export interface WebPerformanceCollectionHealthDiagnostic extends Record { + code?: string; + label?: string; + tone?: string; + detail?: string; +} + +export interface WebPerformanceCollectionHealth extends Record { + schemaVersion?: string; + status?: string; + statusLabel?: string; + reason?: string; + reasonLabel?: string; + sampleCount?: number; + retainedSampleCount?: number; + sampleSeries?: number; + lowSampleThreshold?: number; + firstSampleAt?: string | null; + lastSampleAt?: string | null; + lastRetainedSampleAt?: string | null; + secondsSinceLastSample?: number | null; + windowCoverageSeconds?: number | null; + sourceFamilyCounts?: Array<{ sourceFamily?: string; rowCount?: number; sampleCount?: number; [key: string]: unknown }>; + unknownAttribution?: { workbenchRows?: number; rowsWithUnknown?: number; samplesWithUnknown?: number; backendUnknownRows?: number; transportUnknownRows?: number; [key: string]: unknown }; + diagnostics?: WebPerformanceCollectionHealthDiagnostic[]; +} + export interface WebPerformanceDashboard extends Record { schemaVersion?: string; generatedAt?: string; sampleWindow?: WebPerformanceSampleWindow & { buckets?: Array>; [key: string]: unknown }; freshness?: { observedAt?: string; source?: string; namespace?: string; gitopsTarget?: string; status?: string; statusLabel?: string; lowSampleThreshold?: number; generatedAt?: string; windowFrom?: string | null; windowTo?: string; sampleCount?: number; sourceFamily?: string; [key: string]: unknown }; + collectionHealth?: WebPerformanceCollectionHealth; cards?: WebPerformanceDashboardCard[]; trends?: WebPerformanceDashboardTrend[]; distributions?: WebPerformanceDashboardDistribution[]; @@ -448,6 +476,7 @@ export interface WebPerformanceSummaryResponse { gitopsTarget?: string; sampleWindow?: WebPerformanceSampleWindow; summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; lowSampleThreshold?: number; window?: WebPerformanceWindow | string; windowLabel?: string; windowSeconds?: number | null; windowFrom?: string | null; windowTo?: string; generatedAt?: string; aggregationKind?: "exact" | "histogram" | string; approximation?: "exact" | "bucketed" | string; [key: string]: unknown }; + collectionHealth?: WebPerformanceCollectionHealth; dashboard?: WebPerformanceDashboard; apiRoutes?: WebPerformanceRow[]; webVitals?: WebPerformanceRow[]; diff --git a/web/hwlab-cloud-web/src/views/PerformanceView.vue b/web/hwlab-cloud-web/src/views/PerformanceView.vue index 9b400073..865daf64 100644 --- a/web/hwlab-cloud-web/src/views/PerformanceView.vue +++ b/web/hwlab-cloud-web/src/views/PerformanceView.vue @@ -10,6 +10,7 @@ import PageHeader from "@/components/common/PageHeader.vue"; import TablePageLayout from "@/components/layout/TablePageLayout.vue"; import { useTableLoader } from "@/composables/useTableLoader"; import type { + WebPerformanceCollectionHealthDiagnostic, WebPerformanceDashboardDistribution, WebPerformanceDashboardPoint, WebPerformanceDashboardTopN, @@ -55,6 +56,8 @@ const distributions = computed(() => dashboard.value?.distributions ?? []); const topN = computed(() => dashboard.value?.topN ?? []); const problems = computed(() => payload.value?.problems ?? []); const sampleWindow = computed(() => dashboard.value?.sampleWindow ?? payload.value?.sampleWindow ?? null); +const collectionHealth = computed(() => dashboard.value?.collectionHealth ?? payload.value?.collectionHealth ?? null); +const collectionDiagnostics = computed(() => (collectionHealth.value?.diagnostics ?? []).slice(0, 4)); const detailRows = computed(() => { if (problems.value.length) return problems.value; return [...(payload.value?.workbenchRows ?? []), ...(payload.value?.rows ?? [])]; @@ -87,6 +90,38 @@ const freshnessText = computed(() => { const target = freshness?.gitopsTarget ?? payload.value?.gitopsTarget ?? "-"; return `${sampleWindowText.value} · ${observedAt} · ${source} · ${namespace} / ${target}`; }); +const collectionHealthItems = computed(() => { + const health = collectionHealth.value; + if (!health) return [ + { key: "collection-missing", label: "采集健康", value: "等待字段", detail: "summary 暂未返回 collectionHealth" } + ]; + return [ + { + key: "last-sample", + label: "最近样本", + value: formatTime(String(health.lastSampleAt ?? health.lastRetainedSampleAt ?? "")), + detail: `${String(health.reasonLabel ?? "采集状态未知")} · ${formatSecondsSince(health.secondsSinceLastSample)}` + }, + { + key: "window-samples", + label: "当前窗口样本", + value: `${formatCount(health.sampleCount)} 样本`, + detail: `保留 ${formatCount(health.retainedSampleCount)} 样本 · 覆盖 ${formatWindowCoverage(health.windowCoverageSeconds)}` + }, + { + key: "source-family", + label: "来源组成", + value: sourceFamilySummary(health.sourceFamilyCounts), + detail: `${formatCount(health.sampleSeries)} 个序列 · 低样本阈值 ${formatCount(health.lowSampleThreshold)}` + }, + { + key: "unknown-attribution", + label: "归因完整性", + value: unknownAttributionLabel(health.unknownAttribution), + detail: unknownAttributionDetail(health.unknownAttribution) + } + ]; +}); onMounted(() => void table.reload()); @@ -228,10 +263,45 @@ function rowWindowText(row: WebPerformanceRow): string { } function sourceFamilyLabel(value?: string): string { - const labels: Record = { web: "浏览器", workbench: "工作台", backend: "后端", mixed: "混合" }; + const labels: Record = { web: "浏览器", workbench: "工作台", backend: "后端", mixed: "混合", rum_api_timing: "同源 API", rum_web_vitals: "浏览器指标", rum_navigation: "页面加载", rum_long_task: "长任务", workbench_journey: "工作台体感", workbench_event_phase: "事件阶段", workbench_backend_event_visible: "后端可见", unknown: "未知" }; return labels[String(value ?? "mixed")] ?? String(value); } +function formatSecondsSince(value?: number | null): string { + const number = Number(value); + if (!Number.isFinite(number)) return "没有最近样本"; + if (number < 60) return `${Math.max(0, Math.round(number))} 秒前`; + if (number < 3600) return `${Math.round(number / 60)} 分钟前`; + return `${Math.round(number / 3600)} 小时前`; +} + +function formatWindowCoverage(value?: number | null): string { + const number = Number(value); + if (!Number.isFinite(number)) return "无窗口覆盖"; + if (number < 60) return `${Math.max(0, Math.round(number))} 秒`; + if (number < 3600) return `${Math.round(number / 60)} 分钟`; + return `${Math.round(number / 3600)} 小时`; +} + +function sourceFamilySummary(value?: Array<{ sourceFamily?: string; sampleCount?: number }>): string { + const items = Array.isArray(value) ? value : []; + if (!items.length) return "暂无来源"; + return items.slice(0, 2).map((item) => `${sourceFamilyLabel(item.sourceFamily)} ${formatCount(item.sampleCount)}`).join(" / "); +} + +function unknownAttributionLabel(value?: { rowsWithUnknown?: number }): string { + const rows = Number(value?.rowsWithUnknown ?? 0); + return rows > 0 ? `${formatCount(rows)} 条不完整` : "归因完整"; +} + +function unknownAttributionDetail(value?: { samplesWithUnknown?: number; backendUnknownRows?: number; transportUnknownRows?: number }): string { + const samples = Number(value?.samplesWithUnknown ?? 0); + const backend = Number(value?.backendUnknownRows ?? 0); + const transport = Number(value?.transportUnknownRows ?? 0); + if (samples <= 0 && backend <= 0 && transport <= 0) return "backend / transport 均已归因"; + return `${formatCount(samples)} 样本 · backend unknown ${formatCount(backend)} 条 · transport unknown ${formatCount(transport)} 条`; +} + function formatDimensions(row: WebPerformanceRow): string { const parts = [ row.backend ? `后端:${row.backend}` : "", @@ -358,6 +428,10 @@ function toneClass(tone?: string): string { if (tone === "ok") return "is-ok"; return "is-source"; } + +function diagnosticToneClass(diagnostic: WebPerformanceCollectionHealthDiagnostic): string { + return toneClass(String(diagnostic.tone ?? "source")); +}