Merge pull request #1634 from pikasTech/fix/issue-1632-performance-window

feat: add performance summary time windows
This commit is contained in:
Lyon
2026-06-20 00:30:50 +08:00
committed by GitHub
6 changed files with 279 additions and 31 deletions
+36
View File
@@ -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<string, unknown>);
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<string, unknown>);
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({
+145 -24
View File
@@ -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<string, unknown>;
maxSeries?: number;
maxSamples?: number;
now?: () => number;
}
interface WebPerformancePayload {
@@ -90,6 +101,26 @@ interface NormalizedPerformanceEvent {
counterLabels?: Record<string, string>;
}
interface RecordedPerformanceSample extends NormalizedPerformanceEvent {
atMs: number;
}
interface PerformanceSummaryWindow {
id: keyof typeof PERFORMANCE_SUMMARY_WINDOWS;
label: string;
milliseconds: number | null;
}
interface PerformanceAggregate {
durationSeries: Map<string, HistogramSeries>;
clsSeries: Map<string, HistogramSeries>;
sampleSeries: Map<string, CounterSeries>;
journeyDurationSeries: Map<string, HistogramSeries>;
journeyTotalSeries: Map<string, CounterSeries>;
eventPhaseSeries: Map<string, HistogramSeries>;
backendEventVisibleSeries: Map<string, HistogramSeries>;
}
interface HistogramSeries {
labels: Record<string, string>;
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<string, HistogramSeries>();
const clsSeries = new Map<string, HistogramSeries>();
const sampleSeries = new Map<string, CounterSeries>();
@@ -162,6 +195,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
const journeyTotalSeries = new Map<string, CounterSeries>();
const eventPhaseSeries = new Map<string, HistogramSeries>();
const backendEventVisibleSeries = new Map<string, HistogramSeries>();
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<string, HistogramSeries>, counters: Map<string, CounterSeries>, histogramLabels: Record<string, string>, counterLabels: Record<string, string>, 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<string, string>) {
};
}
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<string, number | string>;
sampleWindow: PerformanceSummaryWindow;
summary: Record<string, number | string | null>;
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)} 个样本` }
],
+3 -2
View File
@@ -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<ApiResult<GateDiagnosticsResponse>> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }),
performanceSummary: (): Promise<ApiResult<WebPerformanceSummaryResponse>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }),
performanceSummary: (window: WebPerformanceWindow = "15m"): Promise<ApiResult<WebPerformanceSummaryResponse>> => fetchJson(`/v1/web-performance/summary?window=${encodeURIComponent(window)}`, { timeoutMs: 12000, timeoutName: "web performance summary" }),
skills: (): Promise<ApiResult<SkillsResponse>> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }),
uploadSkill: (name: string, files: SkillUploadFileInput[]): Promise<ApiResult<SkillUploadResponse>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ name: name.trim() || undefined, files }), timeoutMs: 30000, timeoutName: "skill upload" }),
skillTree: (skillId: string): Promise<ApiResult<SkillTreeResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }),
@@ -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));
+11 -2
View File
@@ -347,6 +347,14 @@ export interface WebPerformanceRow extends Record<string, unknown> {
phase?: string;
}
export type WebPerformanceWindow = "5m" | "15m" | "1h" | "6h" | "24h" | "all";
export interface WebPerformanceSampleWindow extends Record<string, unknown> {
id?: WebPerformanceWindow | string;
label?: string;
seconds?: number | null;
}
export interface WebPerformanceDashboardPoint extends Record<string, unknown> {
label?: string;
detail?: string;
@@ -410,7 +418,7 @@ export interface WebPerformanceDashboardCard extends Record<string, unknown> {
export interface WebPerformanceDashboard extends Record<string, unknown> {
schemaVersion?: string;
generatedAt?: string;
sampleWindow?: { label?: string; bucketKind?: string; buckets?: Array<Record<string, unknown>>; [key: string]: unknown };
sampleWindow?: WebPerformanceSampleWindow & { bucketKind?: string; buckets?: Array<Record<string, unknown>>; [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[];
@@ -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<WebPerformanceSummaryResponse | null>(null);
const currentWindow = ref<WebPerformanceWindow>("15m");
const table = useTableLoader<WebPerformanceRow>(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 {
<template>
<section class="route-stack system-page performance-page">
<PageHeader eyebrow="运维监控" title="性能监控" description="以同一性能摘要展示工作台体感、后端事件可见延迟、API 耗时、浏览器指标和采集健康。" />
<section class="performance-window-toolbar" aria-label="性能时间窗口">
<div class="performance-window-tabs" role="tablist" aria-label="时间窗口">
<button
v-for="option in performanceWindowOptions"
:key="option.value"
type="button"
role="tab"
:aria-selected="currentWindow === option.value"
:data-selected="currentWindow === option.value"
@click="handleWindowChange(option.value)"
>
{{ option.label }}
</button>
</div>
<span>{{ sampleWindowText }}</span>
</section>
<LoadingState v-if="table.loading.value && !table.rows.value.length" />
<section v-else-if="table.error.value" class="data-panel">
<EmptyState title="性能监控加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />