Merge pull request #1634 from pikasTech/fix/issue-1632-performance-window
feat: add performance summary time windows
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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)} 个样本` }
|
||||
],
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user