diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index 8d467af3..f4fcb617 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -54,6 +54,42 @@ 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 filters rows by requested recent window", () => { + 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: 2600, traceId: "trc_old_secret" }] + } as Record); + + nowMs = 20 * 60 * 1000; + 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: 320, traceId: "trc_new_secret" }] + } as Record); + + const recent = store.summary({ window: "5m" }); + const all = store.summary({ window: "all" }); + const recentJson = JSON.stringify(recent); + const allJson = JSON.stringify(all); + + assert.equal(recent.sampleWindow.id, "5m"); + assert.equal(recent.summary.sampleCount, 1); + assert.equal(recent.apiRoutes[0].p95, 0.5); + assert.equal(all.sampleWindow.id, "all"); + assert.equal(all.summary.sampleCount, 2); + assert.equal(all.apiRoutes[0].p95, 5); + assert.doesNotMatch(recentJson, /trc_old_secret|trc_new_secret|traceId|prompt|api key/iu); + assert.doesNotMatch(allJson, /trc_old_secret|trc_new_secret|traceId|prompt|api key/iu); +}); + test("web performance store accepts v2 Workbench journey and phase metrics without high-cardinality labels", () => { const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" } }); const result = store.record({ diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index ac04408b..ba44aee7 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -46,10 +46,21 @@ const WORKBENCH_TARGET_STATES = new Set(["running", "terminal", "empty", "unknow 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; +const DEFAULT_SUMMARY_WINDOW = "15m"; +const PERFORMANCE_SUMMARY_WINDOWS = { + "5m": { id: "5m", label: "最近 5 分钟", milliseconds: 5 * 60 * 1000 }, + "15m": { id: "15m", label: "最近 15 分钟", milliseconds: 15 * 60 * 1000 }, + "1h": { id: "1h", label: "最近 1 小时", milliseconds: 60 * 60 * 1000 }, + "6h": { id: "6h", label: "最近 6 小时", milliseconds: 6 * 60 * 60 * 1000 }, + "24h": { id: "24h", label: "最近 24 小时", milliseconds: 24 * 60 * 60 * 1000 }, + all: { id: "all", label: "全部累计", milliseconds: null } +} as const; interface WebPerformanceStoreOptions { env?: Record; maxSeries?: number; + maxSamples?: number; + now?: () => number; } interface WebPerformancePayload { @@ -90,6 +101,26 @@ interface NormalizedPerformanceEvent { counterLabels?: Record; } +interface RecordedPerformanceSample extends NormalizedPerformanceEvent { + atMs: number; +} + +interface PerformanceSummaryWindow { + id: keyof typeof PERFORMANCE_SUMMARY_WINDOWS; + label: string; + milliseconds: number | null; +} + +interface PerformanceAggregate { + durationSeries: Map; + clsSeries: Map; + sampleSeries: Map; + journeyDurationSeries: Map; + journeyTotalSeries: Map; + eventPhaseSeries: Map; + backendEventVisibleSeries: Map; +} + interface HistogramSeries { labels: Record; buckets: number[]; @@ -154,6 +185,8 @@ interface PerformanceDashboardTopRow { export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) { const env = options.env ?? process.env; const maxSeries = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 240); + const maxSamples = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SAMPLES, options.maxSamples ?? 5000); + const now = typeof options.now === "function" ? options.now : () => Date.now(); const durationSeries = new Map(); const clsSeries = new Map(); const sampleSeries = new Map(); @@ -162,6 +195,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = const journeyTotalSeries = new Map(); const eventPhaseSeries = new Map(); const backendEventVisibleSeries = new Map(); + const samples: RecordedPerformanceSample[] = []; const baseLabels = { service: "hwlab-cloud-web", namespace: sanitizeLabelValue(env.HWLAB_METRICS_NAMESPACE ?? env.POD_NAMESPACE ?? "hwlab-v02"), @@ -184,6 +218,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = dropped += 1; continue; } + rememberSample(event); accepted += 1; } return { accepted, dropped, received: events.length }; @@ -283,6 +318,16 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = return recordHistogram(backendEventVisibleSeries, event.labels, event.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, maxSeries); } + function rememberSample(event: NormalizedPerformanceEvent) { + samples.push({ + ...event, + atMs: now(), + labels: { ...event.labels }, + counterLabels: event.counterLabels ? { ...event.counterLabels } : undefined + }); + if (samples.length > maxSamples) samples.splice(0, samples.length - maxSamples); + } + function recordHistogramWithCounter(histograms: Map, counters: Map, histogramLabels: Record, counterLabels: Record, value: number, buckets: number[]) { if (!canRecordSeries(histograms, histogramLabels, maxSeries) || !canRecordSeries(counters, counterLabels, maxSeries)) return false; recordHistogram(histograms, histogramLabels, value, buckets, maxSeries); @@ -325,17 +370,19 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = return lines.join("\n"); } - function summary() { - const durationRows = histogramSummaryRows(durationSeries, DURATION_BUCKETS_SECONDS, "seconds"); - const clsRows = histogramSummaryRows(clsSeries, CLS_BUCKETS, "score"); + function summary(input: { window?: unknown } = {}) { + const sampleWindow = resolvePerformanceSummaryWindow(input.window); + const aggregate = aggregateForWindow(sampleWindow); + const durationRows = histogramSummaryRows(aggregate.durationSeries, DURATION_BUCKETS_SECONDS, "seconds"); + const clsRows = histogramSummaryRows(aggregate.clsSeries, CLS_BUCKETS, "score"); const rows = [...durationRows, ...clsRows]; - const workbenchJourneys = workbenchJourneySummaryRows(journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS) + const workbenchJourneys = workbenchJourneySummaryRows(aggregate.journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS) .sort(sortByProblemThenP95) .slice(0, 24); - const workbenchEventPhases = workbenchEventPhaseSummaryRows(eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS) + const workbenchEventPhases = workbenchEventPhaseSummaryRows(aggregate.eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS) .sort(sortByProblemThenP95) .slice(0, 24); - const workbenchBackendEvents = workbenchBackendEventVisibleSummaryRows(backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS) + const workbenchBackendEvents = workbenchBackendEventVisibleSummaryRows(aggregate.backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS) .sort(sortByProblemThenP95) .slice(0, 24); const workbenchRows = [...workbenchJourneys, ...workbenchEventPhases, ...workbenchBackendEvents]; @@ -355,24 +402,27 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = .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 sampleCount = [...aggregate.sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0); + const workbenchSampleCount = [...aggregate.journeyTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0) + + histogramSampleCount(aggregate.eventPhaseSeries) + + histogramSampleCount(aggregate.backendEventVisibleSeries); const totalSampleCount = sampleCount + workbenchSampleCount; const summaryInfo = { sampleCount: totalSampleCount, - sampleSeries: sampleSeries.size, - durationSeries: durationSeries.size, - clsSeries: clsSeries.size, + sampleSeries: aggregate.sampleSeries.size, + durationSeries: aggregate.durationSeries.size, + clsSeries: aggregate.clsSeries.size, droppedSeries: droppedSeries.size, - workbenchJourneySeries: journeyDurationSeries.size, - workbenchEventPhaseSeries: eventPhaseSeries.size, - workbenchBackendEventVisibleSeries: backendEventVisibleSeries.size, + workbenchJourneySeries: aggregate.journeyDurationSeries.size, + workbenchEventPhaseSeries: aggregate.eventPhaseSeries.size, + workbenchBackendEventVisibleSeries: aggregate.backendEventVisibleSeries.size, routeCount: new Set([...rows, ...workbenchRows].map((row) => row.route)).size, problemCount: problems.length, status: totalSampleCount === 0 ? "waiting" : totalSampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok", - lowSampleThreshold: LOW_SAMPLE_THRESHOLD + lowSampleThreshold: LOW_SAMPLE_THRESHOLD, + window: sampleWindow.id, + windowLabel: sampleWindow.label, + windowSeconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000) }; const observedAt = new Date().toISOString(); const dashboard = buildPerformanceDashboard({ @@ -380,6 +430,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = source: "cloud-api-rum-store", namespace: baseLabels.namespace, gitopsTarget: baseLabels.gitops_target, + sampleWindow, summary: summaryInfo, apiRoutes, webVitals, @@ -398,6 +449,11 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = observedAt, namespace: baseLabels.namespace, gitopsTarget: baseLabels.gitops_target, + sampleWindow: { + id: sampleWindow.id, + label: sampleWindow.label, + seconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000) + }, summary: summaryInfo, dashboard, apiRoutes, @@ -424,13 +480,66 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = sampleCount: [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0) + [...journeyTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0) + histogramSampleCount(eventPhaseSeries) - + histogramSampleCount(backendEventVisibleSeries) + + histogramSampleCount(backendEventVisibleSeries), + retainedSamples: samples.length, + maxSamples }; } + function aggregateForWindow(sampleWindow: PerformanceSummaryWindow): PerformanceAggregate { + if (sampleWindow.milliseconds === null) return { durationSeries, clsSeries, sampleSeries, journeyDurationSeries, journeyTotalSeries, eventPhaseSeries, backendEventVisibleSeries }; + const aggregate = createPerformanceAggregate(); + const cutoff = now() - sampleWindow.milliseconds; + for (const sample of samples) { + if (sample.atMs >= cutoff) recordSampleIntoAggregate(aggregate, sample); + } + return aggregate; + } + return { record, metricsText, snapshot, summary }; } +function createPerformanceAggregate(): PerformanceAggregate { + return { + durationSeries: new Map(), + clsSeries: new Map(), + sampleSeries: new Map(), + journeyDurationSeries: new Map(), + journeyTotalSeries: new Map(), + eventPhaseSeries: new Map(), + backendEventVisibleSeries: new Map() + }; +} + +function recordSampleIntoAggregate(aggregate: PerformanceAggregate, sample: RecordedPerformanceSample) { + if (sample.series === "layout_shift") { + recordHistogram(aggregate.clsSeries, sample.labels, sample.value, CLS_BUCKETS, Number.MAX_SAFE_INTEGER); + incrementCounter(aggregate.sampleSeries, sample.counterLabels ?? sampleLabels(sample.labels), Number.MAX_SAFE_INTEGER); + return; + } + if (sample.series === "web_duration") { + recordHistogram(aggregate.durationSeries, sample.labels, sample.value, DURATION_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER); + incrementCounter(aggregate.sampleSeries, sample.counterLabels ?? sampleLabels(sample.labels), Number.MAX_SAFE_INTEGER); + return; + } + if (sample.series === "workbench_journey") { + recordHistogram(aggregate.journeyDurationSeries, sample.labels, sample.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER); + incrementCounter(aggregate.journeyTotalSeries, sample.counterLabels ?? sample.labels, Number.MAX_SAFE_INTEGER); + return; + } + if (sample.series === "workbench_event_phase") { + recordHistogram(aggregate.eventPhaseSeries, sample.labels, sample.value, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER); + return; + } + recordHistogram(aggregate.backendEventVisibleSeries, sample.labels, sample.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER); +} + +function resolvePerformanceSummaryWindow(input: unknown): PerformanceSummaryWindow { + const key = String(input ?? DEFAULT_SUMMARY_WINDOW).trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(PERFORMANCE_SUMMARY_WINDOWS, key)) return PERFORMANCE_SUMMARY_WINDOWS[key as keyof typeof PERFORMANCE_SUMMARY_WINDOWS]; + return PERFORMANCE_SUMMARY_WINDOWS[DEFAULT_SUMMARY_WINDOW]; +} + export async function handleWebPerformanceIngestHttp(request, response, options = {}) { const body = await readBody(request, options.bodyLimitBytes ?? DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES); let payload; @@ -465,8 +574,9 @@ export function handleWebPerformanceMetricsHttp(request, response, options = {}) response.end(payload); } -export function handleWebPerformanceSummaryHttp(_request, response, options = {}) { - sendJson(response, 200, options.store.summary()); +export function handleWebPerformanceSummaryHttp(request, response, options = {}) { + const url = requestUrl(request); + sendJson(response, 200, options.store.summary({ window: url.searchParams.get("window") })); } export function webPerformanceRouteTemplate(input: unknown): string { @@ -498,6 +608,14 @@ function sampleLabels(labels: Record) { }; } +function requestUrl(request) { + try { + return new URL(String(request.url ?? "/"), `http://${String(request.headers?.host ?? "hwlab.local")}`); + } catch { + return new URL("/", "http://hwlab.local"); + } +} + function isObservabilityNoise(event: { kind: string; metric: string; route: string; pageRoute: string }) { if (event.pageRoute === "/performance") return true; if (event.route === "/performance" && (event.kind === "navigation" || event.kind === "web_vital" || event.kind === "long_task")) return true; @@ -799,7 +917,8 @@ function buildPerformanceDashboard(input: { source: string; namespace: string; gitopsTarget: string; - summary: Record; + sampleWindow: PerformanceSummaryWindow; + summary: Record; apiRoutes: PerformanceSummaryRow[]; webVitals: PerformanceSummaryRow[]; longTasks: PerformanceSummaryRow[]; @@ -824,9 +943,11 @@ function buildPerformanceDashboard(input: { schemaVersion: "hwlab-web-performance-dashboard-v1", generatedAt: input.observedAt, sampleWindow: { - label: "当前采集窗口", + id: input.sampleWindow.id, + label: input.sampleWindow.label, + seconds: input.sampleWindow.milliseconds === null ? null : Math.round(input.sampleWindow.milliseconds / 1000), bucketKind: "snapshot", - buckets: [{ id: "current", label: "当前窗口", observedAt: input.observedAt, sampleCount }] + buckets: [{ id: input.sampleWindow.id, label: input.sampleWindow.label, observedAt: input.observedAt, sampleCount }] }, freshness: { observedAt: input.observedAt, @@ -839,7 +960,7 @@ function buildPerformanceDashboard(input: { }, cards: [ { id: "health", label: "采集状态", value: dashboardStatusLabel(status), unit: "状态", tone: dashboardTone(status), detail: input.source }, - { id: "samples", label: "样本数", value: sampleCount, unit: "样本", tone: sampleCount === 0 ? "source" : "ok", detail: `${numberField(input.summary.sampleSeries)} 个序列` }, + { id: "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)} 个样本` } ], diff --git a/web/hwlab-cloud-web/src/api/system.ts b/web/hwlab-cloud-web/src/api/system.ts index fa0e3bb1..81c88c17 100644 --- a/web/hwlab-cloud-web/src/api/system.ts +++ b/web/hwlab-cloud-web/src/api/system.ts @@ -6,7 +6,8 @@ import type { SkillTreeResponse, SkillUploadResponse, SkillsResponse, - WebPerformanceSummaryResponse + WebPerformanceSummaryResponse, + WebPerformanceWindow } from "@/types"; export interface SkillUploadFileInput { @@ -16,7 +17,7 @@ export interface SkillUploadFileInput { export const systemAPI = { gateDiagnostics: (): Promise> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }), - performanceSummary: (): Promise> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }), + performanceSummary: (window: WebPerformanceWindow = "15m"): Promise> => fetchJson(`/v1/web-performance/summary?window=${encodeURIComponent(window)}`, { timeoutMs: 12000, timeoutName: "web performance summary" }), skills: (): Promise> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }), uploadSkill: (name: string, files: SkillUploadFileInput[]): Promise> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ name: name.trim() || undefined, files }), timeoutMs: 30000, timeoutName: "skill upload" }), skillTree: (skillId: string): Promise> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }), diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 05f6668d..496643a0 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -2426,6 +2426,49 @@ text-align: right; } +.performance-window-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.performance-window-tabs { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid #cbd5e1; + background: #f8fafc; + border-radius: 8px; +} + +.performance-window-tabs button { + min-width: 56px; + height: 30px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: #475569; + font-size: 0.82rem; + font-weight: 650; + cursor: pointer; +} + +.performance-window-tabs button[data-selected="true"] { + background: #fff; + color: #0f172a; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.14); +} + +.performance-window-toolbar > span { + color: #64748b; + font-size: 0.86rem; + font-weight: 650; +} + .performance-health-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index a7875ac9..24d04cb8 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -347,6 +347,14 @@ export interface WebPerformanceRow extends Record { phase?: string; } +export type WebPerformanceWindow = "5m" | "15m" | "1h" | "6h" | "24h" | "all"; + +export interface WebPerformanceSampleWindow extends Record { + id?: WebPerformanceWindow | string; + label?: string; + seconds?: number | null; +} + export interface WebPerformanceDashboardPoint extends Record { label?: string; detail?: string; @@ -410,7 +418,7 @@ export interface WebPerformanceDashboardCard extends Record { export interface WebPerformanceDashboard extends Record { schemaVersion?: string; generatedAt?: string; - sampleWindow?: { label?: string; bucketKind?: string; buckets?: Array>; [key: string]: unknown }; + sampleWindow?: WebPerformanceSampleWindow & { bucketKind?: string; buckets?: Array>; [key: string]: unknown }; freshness?: { observedAt?: string; source?: string; namespace?: string; gitopsTarget?: string; status?: string; statusLabel?: string; lowSampleThreshold?: number; [key: string]: unknown }; cards?: WebPerformanceDashboardCard[]; trends?: WebPerformanceDashboardTrend[]; @@ -424,7 +432,8 @@ export interface WebPerformanceSummaryResponse { observedAt?: string; namespace?: string; gitopsTarget?: string; - summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; lowSampleThreshold?: number; [key: string]: unknown }; + 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; [key: string]: unknown }; 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 da23a46d..6aaf1d7d 100644 --- a/web/hwlab-cloud-web/src/views/PerformanceView.vue +++ b/web/hwlab-cloud-web/src/views/PerformanceView.vue @@ -15,9 +15,19 @@ import type { WebPerformanceDashboardTopN, WebPerformanceDashboardTrend, WebPerformanceRow, - WebPerformanceSummaryResponse + WebPerformanceSummaryResponse, + WebPerformanceWindow } from "@/types"; +const performanceWindowOptions: Array<{ value: WebPerformanceWindow; label: string }> = [ + { value: "5m", label: "5 分钟" }, + { value: "15m", label: "15 分钟" }, + { value: "1h", label: "1 小时" }, + { value: "6h", label: "6 小时" }, + { value: "24h", label: "24 小时" }, + { value: "all", label: "全部" } +]; + const detailColumns: DataTableColumn[] = [ { key: "metric", label: "指标" }, { key: "route", label: "路由与维度" }, @@ -29,8 +39,9 @@ const detailColumns: DataTableColumn[] = [ ]; const payload = ref(null); +const currentWindow = ref("15m"); const table = useTableLoader(async () => { - const response = await systemAPI.performanceSummary(); + const response = await systemAPI.performanceSummary(currentWindow.value); if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`); payload.value = response.data; return response.data?.rows ?? []; @@ -48,13 +59,14 @@ const detailRows = computed(() => { return [...(payload.value?.workbenchRows ?? []), ...(payload.value?.rows ?? [])]; }); const hasDashboard = computed(() => dashboard.value?.schemaVersion === "hwlab-web-performance-dashboard-v1"); +const sampleWindowText = computed(() => dashboard.value?.sampleWindow?.label ?? payload.value?.sampleWindow?.label ?? windowLabel(currentWindow.value)); const freshnessText = computed(() => { const freshness = dashboard.value?.freshness; const observedAt = formatTime(freshness?.observedAt ?? payload.value?.observedAt); const source = freshness?.source ?? payload.value?.source ?? "-"; const namespace = freshness?.namespace ?? payload.value?.namespace ?? "-"; const target = freshness?.gitopsTarget ?? payload.value?.gitopsTarget ?? "-"; - return `${observedAt} · ${source} · ${namespace} / ${target}`; + return `${sampleWindowText.value} · ${observedAt} · ${source} · ${namespace} / ${target}`; }); onMounted(() => void table.reload()); @@ -63,6 +75,16 @@ function rowKey(row: WebPerformanceRow): string { return `${row.kind ?? "kind"}:${row.metric ?? "metric"}:${row.route ?? "route"}:${row.method ?? "method"}:${row.statusClass ?? "status"}:${row.backend ?? "backend"}:${row.transport ?? "transport"}:${row.eventType ?? "event"}:${row.phase ?? "phase"}`; } +function windowLabel(value: WebPerformanceWindow): string { + return performanceWindowOptions.find((option) => option.value === value)?.label ?? "15 分钟"; +} + +function handleWindowChange(value: WebPerformanceWindow) { + if (currentWindow.value === value) return; + currentWindow.value = value; + void table.reload(); +} + function displayText(value?: string | null): string { return String(value ?? "") .replace(/:sessionId/gu, ":会话") @@ -288,6 +310,22 @@ function toneClass(tone?: string): string {