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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.pnpm-store

# testing
/coverage
Expand Down
6 changes: 3 additions & 3 deletions app/site/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ export default async function SitePage({
}

const rangeLabel =
site.bars.length > 0
? `${new Date(site.bars[0].t * 1000).toLocaleDateString()} – ${new Date(
site.bars[site.bars.length - 1].t * 1000,
site.rangeStart < site.rangeEnd
? `${new Date(site.rangeStart * 1000).toLocaleDateString()} – ${new Date(
site.rangeEnd * 1000,
).toLocaleDateString()}`
: "";

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
33 changes: 33 additions & 0 deletions e2e/site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ async function gotoSite(page: Page, linkName: RegExp): Promise<void> {
await page.goto(href);
}

function uptimeBars(page: Page) {
const chart = page.getByRole("img", { name: "Uptime history" });
return chart.locator("rect");
}

async function lastUptimeBar(page: Page) {
const rects = uptimeBars(page);
const count = await rects.count();
if (count === 0) throw new Error("Uptime history has no bars");
return rects.nth(count - 1);
}

test.describe("site detail page", () => {
test("renders an operational service's details", async ({ page }) => {
await gotoSite(page, /thecontinent\.org/);
Expand Down Expand Up @@ -82,6 +94,27 @@ test.describe("site detail page", () => {
).toBeVisible();
});

test("renders populated uptime bars for long mock windows", async ({
page,
}) => {
await gotoSite(page, /thecontinent\.org/);

const expectedBarCounts = [
["14d", 84],
["30d", 90],
["1y", 90],
] as const;

for (const [window, barCount] of expectedBarCounts) {
await page.getByRole("link", { name: window, exact: true }).click();

await expect(uptimeBars(page)).toHaveCount(barCount);
const finalBar = await lastUptimeBar(page);
await expect(finalBar).not.toHaveAttribute("fill", "var(--bar-empty)");
await expect(finalBar.locator("title")).not.toContainText("no data");
}
});

test("shows an error for an unknown service", async ({ page }) => {
await page.goto("/site/does-not-exist");
await expect(
Expand Down
15 changes: 14 additions & 1 deletion lib/mock.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { bucketPlan } from "./buckets";
import { mockOverview, mockSiteHistory } from "./mock";
import { WINDOW_KEYS } from "./types";

const idOf = (name: string): string =>
mockOverview().find((s) => s.name === name)!.id;

afterEach(() => {
vi.useRealTimers();
});

describe("mockOverview", () => {
it("returns every fixture site", () => {
const sites = mockOverview();
Expand Down Expand Up @@ -94,10 +99,18 @@ describe("mockSiteHistory", () => {
});

it("returns history shaped for the requested window", () => {
const fixedNowTimestampMs = 1_700_000_000_123;
vi.useFakeTimers();
vi.setSystemTime(fixedNowTimestampMs);
const id = idOf("PesaCheck");
const h = mockSiteHistory(id, "24h")!;
const plan = bucketPlan("24h", fixedNowTimestampMs);

expect(h.window).toBe("24h");
expect(h.check.id).toBe(id);
expect(h.rangeStart).toBe(plan.startSec);
expect(h.rangeEnd).toBe(plan.endSec);
expect(h.rangeEnd - h.rangeStart).toBe(plan.stepSec * plan.count);
// 24h plans 48 buckets; bars and response points line up one-to-one.
expect(h.bars).toHaveLength(48);
expect(h.response).toHaveLength(48);
Expand Down
2 changes: 2 additions & 0 deletions lib/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ export function mockSiteHistory(
uptime,
responseMs: site.currentlyUp ? site.baseMs : null,
window,
rangeStart: plan.startSec,
rangeEnd: plan.endSec,
bars,
response,
responseStats,
Expand Down
145 changes: 131 additions & 14 deletions lib/synthetics.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { bucketPlan } from "./buckets";
import { checkId } from "./format";
import { getOverview, getSiteHistory, listChecks } from "./synthetics";

Expand Down Expand Up @@ -96,6 +97,10 @@ beforeEach(() => {
mockConfig.purposeLabel = "";
});

afterEach(() => {
vi.useRealTimers();
});

describe("listChecks", () => {
it("dedupes by job+instance, falls back to check_name, skips incomplete rows, and sorts by name", async () => {
prom.instantQuery.mockResolvedValue([
Expand Down Expand Up @@ -310,11 +315,11 @@ describe("getSiteHistory", () => {

it("builds clamped bars, response points, and per-window uptime", async () => {
wireOverview("100");
// Return readings at the first three grid slots of whatever plan the code
// requests (start/step come from the real bucket plan), so the assertions
// don't depend on the wall clock.
// Return readings at the first three grid slots and final grid slot of
// whatever plan the code requests (start/end/step come from the real bucket
// plan), so the assertions don't depend on the wall clock.
prom.rangeQuery.mockImplementation(
(q: string, start: number, _end: number, step: number) =>
(q: string, start: number, end: number, step: number) =>
Promise.resolve(
q.includes("DURATION")
? [
Expand All @@ -331,9 +336,10 @@ describe("getSiteHistory", () => {
{
metric: {},
values: [
[start + step, "0.5"],
[start + 2 * step, "1"],
[start + 3 * step, "1.5"],
[start, "0.5"],
[start + step, "1"],
[start + 2 * step, "1.5"],
[end - step, "0.75"],
],
},
],
Expand All @@ -353,17 +359,128 @@ describe("getSiteHistory", () => {
"30d": 99.95,
"1y": 99.95,
});
// Bars span the full 24h grid (48 buckets); the three readings fill the
// first slots — the 1.5 fraction is clamped to 1 — and the rest are "no data".
// Bars span the full 24h grid (48 buckets); readings fill the first slots
// and the final slot — the 1.5 fraction is clamped to 1 — and the rest are
// "no data".
expect(history.bars).toHaveLength(48);
expect(history.bars.slice(0, 3).map((b) => b.uptime)).toEqual([0.5, 1, 1]);
expect(history.bars.slice(3).every((b) => b.uptime === null)).toBe(true);
// Grid timestamps ascend by the plan's step and end at the window's edge.
expect(history.bars.at(-1)?.uptime).toBe(0.75);
expect(history.bars.slice(3, -1).every((b) => b.uptime === null)).toBe(
true,
);
// Grid timestamps mark bucket starts; the final bucket ends at the window edge.
expect(history.bars[1].t - history.bars[0].t).toBe(1800);
expect(history.bars.at(-1)!.t + 1800).toBe(
history.bars[0].t + 1800 * history.bars.length,
);
// The response line is left as-is (no grid fill); a non-finite reading is null.
expect(history.response.map((p) => p.ms)).toEqual([240, null, 260]);
});

it("queries long-window uptime bars from the first bucket edge through one extra step", async () => {
const fixedNowTimestampMs = 1_700_000_000_123;
vi.useFakeTimers();
vi.setSystemTime(fixedNowTimestampMs);
wireOverview("100");
prom.rangeQuery.mockResolvedValue([{ metric: {}, values: [] }]);

for (const window of ["14d", "30d", "1y"] as const) {
prom.rangeQuery.mockClear();

const history = (await getSiteHistory(SITE_A_ID, window))!;

const plan = bucketPlan(window, fixedNowTimestampMs);
const uptimeCall = prom.rangeQuery.mock.calls.find(([q]) =>
String(q).includes("SUCCESS"),
);
expect(uptimeCall).toBeTruthy();
expect(uptimeCall?.slice(1)).toEqual([
plan.startSec + plan.stepSec,
plan.endSec + plan.stepSec,
plan.stepSec,
]);
expect(history.rangeStart).toBe(plan.startSec);
expect(history.rangeEnd).toBe(plan.endSec);
expect(history.rangeEnd - history.rangeStart).toBe(
plan.stepSec * plan.count,
);
}
});

it("maps uptime samples at bucket edges and ignores samples outside the fixed grid", async () => {
wireOverview("100");
prom.rangeQuery.mockImplementation(
(q: string, queryStart: number, queryEnd: number, step: number) => {
if (q.includes("DURATION")) {
return Promise.resolve([{ metric: {}, values: [] }]);
}
const gridStart = queryStart - step;
const gridEnd = queryEnd - step;
return Promise.resolve([
{
metric: {},
values: [
[gridStart, "0.1"],
[gridStart + step, "0.2"],
[gridEnd, "0.3"],
[gridEnd + step, "0.4"],
],
},
]);
},
);

const history = (await getSiteHistory(SITE_A_ID, "30d"))!;

expect(history.bars).toHaveLength(90);
expect(history.bars[0].uptime).toBe(0.2);
expect(history.bars.at(-1)?.uptime).toBe(0.3);
expect(history.bars.slice(1, -1).every((b) => b.uptime === null)).toBe(
true,
);
});

it("populates the final uptime bar inside a retained span for beyond-retention windows", async () => {
const fixedNowTimestampMs = 1_700_000_000_123;
const retained = 14 * 86_400;
mockConfig.retentionDays = 14;
vi.useFakeTimers();
vi.setSystemTime(fixedNowTimestampMs);
wireOverview("100");
prom.rangeQuery.mockImplementation(
(q: string, _queryStart: number, queryEnd: number, step: number) => {
if (q.includes("DURATION")) {
return Promise.resolve([{ metric: {}, values: [] }]);
}
return Promise.resolve([
{ metric: {}, values: [[queryEnd - step, "0.87"]] },
]);
},
);

const history = (await getSiteHistory(SITE_A_ID, "1y"))!;
const plan = bucketPlan("1y", fixedNowTimestampMs, retained);
const uptimeCall = prom.rangeQuery.mock.calls.find(([q]) =>
String(q).includes("SUCCESS"),
);

expect(uptimeCall?.slice(1)).toEqual([
plan.startSec + plan.stepSec,
plan.endSec + plan.stepSec,
plan.stepSec,
]);
expect(history.bars).toHaveLength(plan.count);
expect(history.rangeStart).toBe(plan.startSec);
expect(history.rangeEnd).toBe(plan.endSec);
expect(history.rangeEnd - history.rangeStart).toBe(
plan.stepSec * plan.count,
);
expect(history.rangeEnd).toBe(Math.floor(fixedNowTimestampMs / 1000));
expect(history.rangeEnd - history.rangeStart).toBeLessThanOrEqual(retained);
expect(history.bars.at(-1)?.uptime).toBe(0.87);
expect(history.uptime["1y"]).toBeNull();
});

it("derives min/avg/max from a fixed-resolution series, independent of the window's buckets", async () => {
wireOverview("0");
prom.rangeQuery.mockResolvedValue([{ metric: {}, values: [[100, "240"]] }]);
Expand Down Expand Up @@ -403,8 +520,8 @@ describe("getSiteHistory", () => {
// The uptime strip keeps the 1y bar count but spans only the retained ~14
// days (89 steps between the first and last bar), so it stays dense.
const RETAINED = 14 * 86_400;
const span = history.bars[history.bars.length - 1].t - history.bars[0].t;
expect(span).toBeLessThan(RETAINED);
const span = history.rangeEnd - history.rangeStart;
expect(span).toBeLessThanOrEqual(RETAINED);
expect(span).toBeGreaterThan(
RETAINED - (RETAINED / history.bars.length) * 2,
);
Expand Down
11 changes: 9 additions & 2 deletions lib/synthetics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ async function fetchSiteHistory(
const respPlan = responsePlan(window, undefined, retention ?? undefined);
const barStep = `${barPlan.stepSec}s`;
const respStep = `${respPlan.stepSec}s`;
const barQueryStart = barPlan.startSec + barPlan.stepSec;
const barQueryEnd = barPlan.endSec + barPlan.stepSec;

const barExpr =
`sum(rate(${M.successSum}${sel}[${barStep}]))` +
Expand Down Expand Up @@ -251,7 +253,10 @@ async function fetchSiteHistory(
instantQuery(
`1000 * sum(rate(${M.durationSum}${sel}[24h])) / sum(rate(${M.durationCount}${sel}[24h]))`,
),
rangeQuery(barExpr, barPlan.startSec, barPlan.endSec, barPlan.stepSec),
// Each rate() sample at timestamp T describes the bucket ending at T. Start
// at the first bucket edge, and ask one step past the chart so backends that
// omit the exact end timestamp still return the final in-window bucket.
rangeQuery(barExpr, barQueryStart, barQueryEnd, barPlan.stepSec),
rangeQuery(respExpr, respPlan.startSec, respPlan.endSec, respPlan.stepSec),
instantQuery(`min_over_time(${statRange})`),
instantQuery(`avg_over_time(${statRange})`),
Expand Down Expand Up @@ -289,7 +294,7 @@ async function fetchSiteHistory(
const bars: UptimeBucket[] = Array.from(
{ length: barPlan.count },
(_, i) => ({
t: barPlan.startSec + (i + 1) * barPlan.stepSec,
t: barPlan.startSec + i * barPlan.stepSec,
uptime: barByIndex.get(i) ?? null,
}),
);
Expand Down Expand Up @@ -321,6 +326,8 @@ async function fetchSiteHistory(
uptime,
responseMs: Number.isFinite(respValue) ? respValue : null,
window,
rangeStart: barPlan.startSec,
rangeEnd: barPlan.endSec,
bars,
response,
responseStats,
Expand Down
3 changes: 3 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ export interface SiteHistory {
responseMs: number | null;
/** Window the bars / chart below correspond to. */
window: WindowKey;
/** Chart interval represented by the bucket grid. */
rangeStart: number;
rangeEnd: number;
bars: UptimeBucket[];
response: ResponsePoint[];
/** Window-consistent min/avg/max latency, independent of bucket width. */
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ export default defineConfig({
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
env: { MOCK: "1" },
env: { MOCK: "1", METRICS_RETENTION_DAYS: "0" },
},
});