Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions frontend/src/helpers/statistics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { median, rollingMedian } from "./statistics";

describe("median", () => {
it("returns null for an empty list", () => {
expect(median([])).toBeNull();
});

it("returns the middle value of an odd count", () => {
expect(median([300, 100, 200])).toBe(200);
});

it("averages the two middle values of an even count", () => {
expect(median([400, 100, 300, 200])).toBe(250);
});
});

describe("rollingMedian", () => {
it("uses the shorter window at the start of the series", () => {
expect(rollingMedian([100, 300, 200, 900, 400], 3)).toEqual([100, 200, 200, 300, 400]);
});

it("ignores a single outlier", () => {
expect(rollingMedian([100, 100, 5000, 100, 100], 3)).toEqual([100, 100, 100, 100, 100]);
});

it("skips nulls instead of counting them as zero", () => {
expect(rollingMedian([100, null, 300], 3)).toEqual([100, 100, 200]);
});

it("returns null for a window that holds only nulls", () => {
expect(rollingMedian([null, null, 100], 2)).toEqual([null, null, 100]);
});
});
19 changes: 19 additions & 0 deletions frontend/src/helpers/statistics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Median of the given values, or null for an empty list. */
export function median(values: number[]): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
}

/**
* Median of each value and the up to `window - 1` values before it. Nulls are
* skipped, so a missing measurement does not pull the median down. The first
* entries use the shorter window that exists, so the result starts at index 0.
*/
export function rollingMedian(values: (number | null)[], window: number): (number | null)[] {
return values.map((_, index) => {
const slice = values.slice(Math.max(0, index - window + 1), index + 1);
return median(slice.filter((value): value is number => value != null));
});
}
2 changes: 2 additions & 0 deletions frontend/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,8 @@
"last7Days": "Letzte 7 Tage",
"last30Days": "Letzte 30 Tage",
"last90Days": "Letzte 90 Tage",
"medianTrend": "Median-Trend",
"medianLabel": "{label} (Median)",
"hideFromChart": "Diesen Lauf in den Diagrammen ausblenden",
"showInChart": "Diesen Lauf in den Diagrammen anzeigen",
"showAllInChart": "Alle Läufe anzeigen",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,8 @@
"last7Days": "Last 7 days",
"last30Days": "Last 30 days",
"last90Days": "Last 90 days",
"medianTrend": "Median trend",
"medianLabel": "{label} (median)",
"hideFromChart": "Hide this run from the charts",
"showInChart": "Show this run in the charts",
"showAllInChart": "Show all runs",
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/views/environment/detail/DetailSitespeed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ function lastChartTimestamps(): number[] {
return points.map((point) => point.x);
}

/** Dataset counts of the three charts (performance, transfer size, CLS) of the most recent render. */
function lastRenderDatasetCounts(): number[] {
return chartConfigs.slice(-3).map((config) => config.data.datasets.length);
}

async function settleCharts() {
await flushPromises();
// Two nested requestAnimationFrame hops before the charts are created.
Expand Down Expand Up @@ -204,4 +209,60 @@ describe("DetailSitespeed", () => {
new Date(recentRun.createdAt).getTime(),
]);
});

it("adds a rolling median per metric that reaches back before the timespan", async () => {
mountComponent();
await settleCharts();

expect(lastRenderDatasetCounts()).toEqual([8, 2, 2]);
// Only the two runs inside 30 days are plotted, but the first median also
// includes the 60 day old run: median(300, 200) = 250.
const ttfbMedian = chartConfigs[chartConfigs.length - 3].data.datasets[4];
expect(ttfbMedian.label).toBe("TTFB (median)");
expect((ttfbMedian.data as Array<{ y: number }>).map((point) => point.y)).toEqual([250, 200]);
// The median datasets come after the raw ones and reuse their metric's unit.
const label = chartConfigs[chartConfigs.length - 3].options?.plugins?.tooltip?.callbacks
?.label as unknown as (ctx: unknown) => string;
expect(label({ dataset: ttfbMedian, datasetIndex: 4, parsed: { y: 250 } })).toBe(
"TTFB (median): 250ms",
);
});

it("leaves hidden runs out of the median", async () => {
const wrapper = mountComponent();
await settleCharts();

// The table lists every run newest first, so row 3 is the 60 day old run.
await wrapper.findAll('button[aria-label="Hide this run from the charts"]')[2].trigger("click");
await settleCharts();

const ttfbMedian = chartConfigs[chartConfigs.length - 3].data.datasets[4];
expect((ttfbMedian.data as Array<{ y: number }>).map((point) => point.y)).toEqual([200, 150]);
});

it("plots a missing metric as 0 but leaves it out of the median", async () => {
environment.value = {
...environment.value,
sitespeeds: [{ ...recentRun, ttfb: null, transferSize: 0 }, secondRecentRun, oldRun],
};
mountComponent();
await settleCharts();

const [ttfb, , , , ttfbMedian] = chartConfigs[chartConfigs.length - 3].data.datasets;
expect((ttfb.data as Array<{ y: number }>).map((point) => point.y)).toEqual([200, 0]);
expect((ttfbMedian.data as Array<{ y: number }>).map((point) => point.y)).toEqual([250, 250]);
// A reported 0 is a value, not a missing metric: median(3, 2, 0) KB = 2.
const transferMedian = chartConfigs[chartConfigs.length - 2].data.datasets[1];
expect((transferMedian.data as Array<{ y: number }>).map((point) => point.y)).toEqual([3, 2]);
});

it("drops the median lines when the trend is switched off", async () => {
const wrapper = mountComponent();
await settleCharts();

await wrapper.find('button[role="switch"]').trigger("click");
await settleCharts();

expect(lastRenderDatasetCounts()).toEqual([4, 1, 1]);
});
});
73 changes: 58 additions & 15 deletions frontend/src/views/environment/detail/DetailSitespeed.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-lg font-semibold">{{ $t("shopDetail.performanceOverTime") }}</h2>
<div class="flex items-center gap-2">
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Switch v-model="showTrend" />
{{ $t("sitespeed.medianTrend") }}
</label>
<Select v-model="timespan">
<SelectTrigger class="w-44" :aria-label="$t('sitespeed.timespan')">
<SelectValue :placeholder="$t('sitespeed.timespan')" />
Expand Down Expand Up @@ -204,6 +208,7 @@ import { Chart, registerables } from "chart.js";
import annotationPlugin from "chartjs-plugin-annotation";
import "chartjs-adapter-date-fns";
import { formatDateTime } from "@/helpers/formatter";
import { rollingMedian } from "@/helpers/statistics";
import { useEnvironmentDetail } from "@/composables/useEnvironmentDetail";
import { useI18n } from "vue-i18n";

Expand All @@ -215,6 +220,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import DataTable from "@/components/layout/DataTable.vue";
Expand Down Expand Up @@ -275,6 +281,11 @@ const visibleSitespeeds = computed(() => {
);
});

// Rolling median over the last 7 runs (about a week of daily scrapes), so a
// single outlier run does not hide whether the shop is getting slower.
const MEDIAN_WINDOW_RUNS = 7;
const showTrend = ref(true);

// Latest entry
const latest = computed(() => {
const entries = environment.value?.sitespeeds;
Expand Down Expand Up @@ -429,38 +440,45 @@ interface ChartConfig {
chartInstance: Ref<Chart | null>;
title: string;
yAxisLabel: string;
roundMedian: (value: number) => number;
datasets: Array<{
label: string;
valueFormatter: (item: SitespeedDataItem) => number;
// null for a missing metric, so the median skips it instead of counting a 0.
valueFormatter: (item: SitespeedDataItem) => number | null;
tooltipFormatter: (value: number) => string;
}>;
}

// Explicit colors, since Chart.js would otherwise color by dataset index and the
// median datasets would shift them. The median reuses its metric's color at ~60% alpha.
const chartColors = ["#36a2eb", "#ff6384", "#ff9f40", "#ffcd56"];

const chartConfigs: ChartConfig[] = [
{
canvasRef: timeChartCanvas,
chartInstance: timeChartInstance,
title: t("sitespeed.performanceOverTime"),
yAxisLabel: t("sitespeed.timeMs"),
roundMedian: Math.round,
datasets: [
{
label: t("sitespeed.ttfb"),
valueFormatter: (i) => i.ttfb ?? 0,
valueFormatter: (i) => i.ttfb ?? null,
tooltipFormatter: (v) => `${v}ms`,
},
{
label: t("sitespeed.fullyLoaded"),
valueFormatter: (i) => i.fullyLoaded ?? 0,
valueFormatter: (i) => i.fullyLoaded ?? null,
tooltipFormatter: (v) => `${v}ms`,
},
{
label: t("sitespeed.lcp"),
valueFormatter: (i) => i.largestContentfulPaint ?? 0,
valueFormatter: (i) => i.largestContentfulPaint ?? null,
tooltipFormatter: (v) => `${v}ms`,
},
{
label: t("sitespeed.fcp"),
valueFormatter: (i) => i.firstContentfulPaint ?? 0,
valueFormatter: (i) => i.firstContentfulPaint ?? null,
tooltipFormatter: (v) => `${v}ms`,
},
],
Expand All @@ -470,10 +488,11 @@ const chartConfigs: ChartConfig[] = [
chartInstance: transferSizeChartInstance,
title: t("sitespeed.transferSizeOverTime"),
yAxisLabel: t("sitespeed.sizeKb"),
roundMedian: Math.round,
datasets: [
{
label: t("sitespeed.transferSize"),
valueFormatter: (i) => (i.transferSize ? Math.round(i.transferSize / 1024) : 0),
valueFormatter: (i) => (i.transferSize != null ? Math.round(i.transferSize / 1024) : null),
tooltipFormatter: (v) => `${v} KB`,
},
],
Expand All @@ -483,10 +502,11 @@ const chartConfigs: ChartConfig[] = [
chartInstance: clsChartInstance,
title: t("sitespeed.clsOverTime"),
yAxisLabel: t("sitespeed.clsScore"),
roundMedian: (v) => Math.round(v * 1000) / 1000,
datasets: [
{
label: t("sitespeed.cls"),
valueFormatter: (i) => i.cumulativeLayoutShift ?? 0,
valueFormatter: (i) => i.cumulativeLayoutShift ?? null,
tooltipFormatter: (v) => `${v}`,
},
],
Expand All @@ -503,6 +523,9 @@ function createChart(config: ChartConfig) {
const sorted = [...visibleSitespeeds.value].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
// Oldest first and not cut to the timespan, so the median at the left edge also
// has earlier runs behind it. The visible runs are its tail.
const history = (environment.value?.sitespeeds ?? []).filter((r) => !isRunHidden(r)).reverse();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const annotations: Record<string, any> = {};
Expand Down Expand Up @@ -530,13 +553,33 @@ function createChart(config: ChartConfig) {
config.chartInstance.value = new Chart(ctx, {
type: "line",
data: {
datasets: config.datasets.map((ds) => ({
label: ds.label,
data: sorted.map((item) => ({
x: new Date(item.createdAt).getTime(),
y: ds.valueFormatter(item),
datasets: [
...config.datasets.map((ds, i) => ({
label: ds.label,
borderColor: chartColors[i],
backgroundColor: chartColors[i],
data: sorted.map((item) => ({
x: new Date(item.createdAt).getTime(),
y: ds.valueFormatter(item) ?? 0,
})),
})),
})),
...(showTrend.value ? config.datasets : []).map((ds, i) => {
const medians = rollingMedian(history.map(ds.valueFormatter), MEDIAN_WINDOW_RUNS)
.slice(history.length - sorted.length)
.map((median) => (median == null ? null : config.roundMedian(median)));
return {
label: t("sitespeed.medianLabel", { label: ds.label }),
borderColor: chartColors[i] + "99",
backgroundColor: chartColors[i] + "99",
borderWidth: 1.5,
pointRadius: 0,
data: sorted.map((item, j) => ({
x: new Date(item.createdAt).getTime(),
y: medians[j],
})),
};
}),
],
},
options: {
responsive: true,
Expand All @@ -552,7 +595,7 @@ function createChart(config: ChartConfig) {
tooltip: {
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${config.datasets[ctx.datasetIndex].tooltipFormatter(ctx.parsed.y ?? 0)}`,
`${ctx.dataset.label}: ${config.datasets[ctx.datasetIndex % config.datasets.length].tooltipFormatter(ctx.parsed.y ?? 0)}`,
title: (items) =>
items.length && items[0].parsed.x != null
? new Date(items[0].parsed.x).toLocaleString()
Expand Down Expand Up @@ -618,5 +661,5 @@ watch(
{ deep: true },
);

watch([hiddenRuns, timespan], () => void renderCharts());
watch([hiddenRuns, timespan, showTrend], () => void renderCharts());
</script>
Loading