diff --git a/.gitignore b/.gitignore index 5cbf247..d95781b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ !.yarn/plugins !.yarn/releases !.yarn/versions +.pnpm-store # testing /coverage diff --git a/app/site/[id]/page.tsx b/app/site/[id]/page.tsx index 0f68e2d..72a9269 100644 --- a/app/site/[id]/page.tsx +++ b/app/site/[id]/page.tsx @@ -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()}` : ""; diff --git a/biome.json b/biome.json index f19f88f..ef149a7 100644 --- a/biome.json +++ b/biome.json @@ -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", diff --git a/e2e/site.spec.ts b/e2e/site.spec.ts index e48b946..5be2f05 100644 --- a/e2e/site.spec.ts +++ b/e2e/site.spec.ts @@ -14,6 +14,18 @@ async function gotoSite(page: Page, linkName: RegExp): Promise { 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/); @@ -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( diff --git a/lib/mock.test.ts b/lib/mock.test.ts index ada498e..a14a90f 100644 --- a/lib/mock.test.ts +++ b/lib/mock.test.ts @@ -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(); @@ -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); diff --git a/lib/mock.ts b/lib/mock.ts index f1a466c..557b3a2 100644 --- a/lib/mock.ts +++ b/lib/mock.ts @@ -274,6 +274,8 @@ export function mockSiteHistory( uptime, responseMs: site.currentlyUp ? site.baseMs : null, window, + rangeStart: plan.startSec, + rangeEnd: plan.endSec, bars, response, responseStats, diff --git a/lib/synthetics.test.ts b/lib/synthetics.test.ts index acb9608..9f41f5c 100644 --- a/lib/synthetics.test.ts +++ b/lib/synthetics.test.ts @@ -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"; @@ -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([ @@ -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") ? [ @@ -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"], ], }, ], @@ -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"]] }]); @@ -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, ); diff --git a/lib/synthetics.ts b/lib/synthetics.ts index 91d70b9..a47a9ac 100644 --- a/lib/synthetics.ts +++ b/lib/synthetics.ts @@ -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}]))` + @@ -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})`), @@ -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, }), ); @@ -321,6 +326,8 @@ async function fetchSiteHistory( uptime, responseMs: Number.isFinite(respValue) ? respValue : null, window, + rangeStart: barPlan.startSec, + rangeEnd: barPlan.endSec, bars, response, responseStats, diff --git a/lib/types.ts b/lib/types.ts index 0cb2aae..6eacb00 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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. */ diff --git a/playwright.config.ts b/playwright.config.ts index 799ed28..7a3254c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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" }, }, });