diff --git a/app/site/[id]/page.test.tsx b/app/site/[id]/page.test.tsx new file mode 100644 index 0000000..2d6dfad --- /dev/null +++ b/app/site/[id]/page.test.tsx @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SiteHistory } from "@/lib/types"; +import { generateMetadata } from "./page"; + +const { mockConfig, getSiteHistory } = vi.hoisted(() => ({ + mockConfig: { siteName: "Acme" }, + getSiteHistory: vi.fn(), +})); + +vi.mock("@/lib/config", () => ({ config: mockConfig })); +vi.mock("@/lib/synthetics", () => ({ getSiteHistory })); + +function siteWith(job: string, target: string): SiteHistory { + return { + check: { id: "the-id", name: job, target, job, instance: target }, + } as SiteHistory; +} + +const meta = (id: string) => + generateMetadata({ params: Promise.resolve({ id }) }); + +beforeEach(() => { + getSiteHistory.mockReset(); +}); + +describe("site detail generateMetadata", () => { + it("combines job and target without the check id", async () => { + getSiteHistory.mockResolvedValue( + siteWith("PesaCheck", "https://pesacheck.org"), + ); + + expect(await meta("pesacheck-abc123")).toEqual({ + title: "PesaCheck · pesacheck.org · Acme Status", + }); + }); + + it("dedupes when job and target are identical", async () => { + getSiteHistory.mockResolvedValue( + siteWith("academy.africa", "https://academy.africa"), + ); + + expect(await meta("academy-africa-abc123")).toEqual({ + title: "academy.africa · Acme Status", + }); + }); + + it("dedupes when job and target only differ by a trailing slash", async () => { + getSiteHistory.mockResolvedValue( + siteWith("academy.africa", "https://academy.africa/"), + ); + + expect(await meta("academy-africa-abc123")).toEqual({ + title: "academy.africa · Acme Status", + }); + }); + + it("falls back to the id when the check is not found", async () => { + getSiteHistory.mockResolvedValue(null); + + expect(await meta("unknown-id")).toEqual({ + title: "unknown-id · Acme Status", + }); + }); + + it("falls back to the id when the lookup throws", async () => { + getSiteHistory.mockRejectedValue(new Error("boom")); + + expect(await meta("some-id")).toEqual({ + title: "some-id · Acme Status", + }); + }); +}); diff --git a/app/site/[id]/page.tsx b/app/site/[id]/page.tsx index 7dd22cc..f2f7d83 100644 --- a/app/site/[id]/page.tsx +++ b/app/site/[id]/page.tsx @@ -29,7 +29,18 @@ export async function generateMetadata({ params: Promise<{ id: string }>; }): Promise { const { id } = await params; - return { title: `${id} · ${config.siteName} Status` }; + const site = await getSiteHistory(id, "7d").catch(() => null); + let label = id; + if (site) { + const target = site.check.target + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + label = + site.check.job && site.check.job !== target + ? `${site.check.job} · ${target}` + : site.check.job || target; + } + return { title: `${label} · ${config.siteName} Status` }; } export default async function SitePage({ diff --git a/docs/architecture.md b/docs/architecture.md index 8ca3bec..bae1ef9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,6 +47,18 @@ already-computed numbers and markup. check in Grafana is automatically reflected — there is no service list to maintain in this repo. See `listChecks()`. +- **Check ids identify a `(job, target)` pair, not a job name.** Grafana defines + a check's identity as job name + target, so two checks can share a job name (or + two job names can normalize to the same slug, e.g. `Public API` and + `Public-API`). Each check's id is therefore `-`, where the hash + is a stable digest of the full `(job, target)` identity - see `checkId()` in + [`lib/format.ts`](../lib/format.ts). Because the hash depends only on the + check's own identity, an id never changes when some *other* check is added, + removed, or renamed; it only changes if that check's own job or target changes. + The readable slug leads so `/site/` URLs stay scannable and sort by service + name. Migration note: ids are `slug-hash`, not a bare slug, so any externally + saved deep links must be regenerated. + - **Uptime is computed from `probe_all_*` counters.** On Grafana Cloud the raw `probe_success` metric is aggregated and can't be queried directly, so uptime and current status are derived from the success sum/count counters over the diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts index 453f102..2b2ebf0 100644 --- a/e2e/home.spec.ts +++ b/e2e/home.spec.ts @@ -1,9 +1,9 @@ import { expect, test } from "@playwright/test"; // These assertions rely on the deterministic fixtures in lib/mock.ts (the -// server runs with MOCK=1, see playwright.config.ts). Of the six fixture +// server runs with MOCK=1, see playwright.config.ts). Of the eight fixture // services, only "africanDRONE" is down, so the system is a partial outage -// with five operational. +// with seven operational. test.describe("home page", () => { test("shows the overall status banner and operational count", async ({ page, @@ -12,7 +12,7 @@ test.describe("home page", () => { await expect( page.getByRole("heading", { name: "Partial system outage" }), ).toBeVisible(); - await expect(page.getByText("5/6 services operational")).toBeVisible(); + await expect(page.getByText("7/8 services operational")).toBeVisible(); }); test("notes that sample data is in use", async ({ page }) => { @@ -22,13 +22,14 @@ test.describe("home page", () => { test("links each service to its detail page", async ({ page }) => { await page.goto("/"); + // Ids are the readable slug plus a stable hash suffix. await expect(page.getByRole("link", { name: /PesaCheck/ })).toHaveAttribute( "href", - "/site/pesacheck", + /^\/site\/pesacheck-[a-z0-9]+$/, ); await expect( page.getByRole("link", { name: /africanDRONE/ }), - ).toHaveAttribute("href", "/site/african-drone"); + ).toHaveAttribute("href", /^\/site\/africandrone-[a-z0-9]+$/); }); test("navigates to a service detail page when a row is clicked", async ({ @@ -36,9 +37,40 @@ test.describe("home page", () => { }) => { await page.goto("/"); await page.getByRole("link", { name: /PesaCheck/ }).click(); - await expect(page).toHaveURL("/site/pesacheck"); + await expect(page).toHaveURL(/\/site\/pesacheck-[a-z0-9]+$/); await expect( page.getByRole("heading", { name: "PesaCheck" }), ).toBeVisible(); }); + + // "Public API" and "Public-API" both slugify to "public-api" but target + // different URLs — the classic collision this fixture guards against. Each + // row must have its own link, and each link must open the intended target. + test("gives checks with colliding job slugs distinct, working links", async ({ + page, + }) => { + await page.goto("/"); + + // Rows are disambiguated by their (unique) targets in the link label. + const orgLink = page.getByRole("link", { name: /api\.example\.org/ }); + const netLink = page.getByRole("link", { name: /api\.example\.net/ }); + + const orgHref = await orgLink.getAttribute("href"); + const netHref = await netLink.getAttribute("href"); + expect(orgHref).toBeTruthy(); + expect(netHref).toBeTruthy(); + expect(orgHref).not.toBe(netHref); + + // Each link resolves to a detail page for its own target. + await orgLink.click(); + await expect( + page.getByRole("link", { name: /api\.example\.org/ }), + ).toHaveAttribute("href", "https://api.example.org"); + + await page.goto("/"); + await netLink.click(); + await expect( + page.getByRole("link", { name: /api\.example\.net/ }), + ).toHaveAttribute("href", "https://api.example.net"); + }); }); diff --git a/e2e/site.spec.ts b/e2e/site.spec.ts index e275785..8a98a20 100644 --- a/e2e/site.spec.ts +++ b/e2e/site.spec.ts @@ -1,10 +1,21 @@ -import { expect, test } from "@playwright/test"; +import { expect, type Page, test } from "@playwright/test"; // Detail page assertions against the deterministic MOCK fixtures (lib/mock.ts): -// "pesacheck" is operational, "african-drone" is down. +// "PesaCheck" is operational, "africanDRONE" is down. Public ids carry a hash +// suffix, so resolve each detail URL from its overview link rather than +// hardcoding it. +async function gotoSite(page: Page, linkName: RegExp): Promise { + await page.goto("/"); + const href = await page + .getByRole("link", { name: linkName }) + .getAttribute("href"); + if (!href) throw new Error(`No overview link matching ${linkName}`); + await page.goto(href); +} + test.describe("site detail page", () => { test("renders an operational service's details", async ({ page }) => { - await page.goto("/site/pesacheck"); + await gotoSite(page, /PesaCheck/); await expect( page.getByRole("heading", { name: "PesaCheck" }), @@ -43,7 +54,7 @@ test.describe("site detail page", () => { test("marks a down service as down with no current response time", async ({ page, }) => { - await page.goto("/site/african-drone"); + await gotoSite(page, /africanDRONE/); await expect( page.getByRole("heading", { name: "africanDRONE" }), ).toBeVisible(); @@ -53,9 +64,9 @@ test.describe("site detail page", () => { }); test("switches the window via the tab links", async ({ page }) => { - await page.goto("/site/pesacheck"); + await gotoSite(page, /PesaCheck/); await page.getByRole("link", { name: "30d", exact: true }).click(); - await expect(page).toHaveURL("/site/pesacheck?window=30d"); + await expect(page).toHaveURL(/\/site\/pesacheck-[a-z0-9]+\?window=30d$/); await expect( page.getByRole("heading", { name: "PesaCheck" }), ).toBeVisible(); diff --git a/lib/format.test.ts b/lib/format.test.ts index ce94804..7373aef 100644 --- a/lib/format.test.ts +++ b/lib/format.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { barColor, + checkId, deriveStatus, fmtMs, fmtPct, @@ -34,6 +35,59 @@ describe("slugify", () => { }); }); +describe("checkId", () => { + it("prefixes the readable job slug and appends a hash suffix", () => { + expect(checkId("PesaCheck", "https://pesacheck.org")).toMatch( + /^pesacheck-[a-z0-9]+$/, + ); + expect(checkId("The Continent", "https://thecontinent.org")).toMatch( + /^the-continent-[a-z0-9]+$/, + ); + }); + + it("gives checks that share a job name but target different URLs distinct ids", () => { + const a = checkId("Public API", "https://api.example.org"); + const b = checkId("Public API", "https://api.example.net"); + expect(a).not.toBe(b); + expect(a.startsWith("public-api-")).toBe(true); + expect(b.startsWith("public-api-")).toBe(true); + }); + + it("distinguishes job names that collide only after slug normalization", () => { + const a = checkId("Public API", "https://api.example.org"); + const b = checkId("Public-API", "https://api.example.org"); + const c = checkId("Public.API", "https://api.example.org"); + expect(a).not.toBe(b); + expect(a).not.toBe(c); + expect(b).not.toBe(c); + expect(a.startsWith("public-api-")).toBe(true); + expect(b.startsWith("public-api-")).toBe(true); + expect(c.startsWith("public-api-")).toBe(true); + }); + + it("is deterministic and depends only on the check's own identity", () => { + // Same identity → same id, every call. No other check can influence it. + expect(checkId("Public API", "https://api.example.org")).toBe( + checkId("Public API", "https://api.example.org"), + ); + }); + + it("suffixes a 64-bit hash (pins the digest against a narrower regression)", () => { + // Pinned value from a 64-bit FNV-1a over "Public API https://api.example.org". + // A regression to the old 32-bit digest would change this suffix. + expect(checkId("Public API", "https://api.example.org")).toBe( + "public-api-3v89g0yt4y0l", + ); + }); + + it("falls back to the target slug, then 'check', for an empty job", () => { + expect(checkId("", "https://example.org")).toMatch( + /^example-org-[a-z0-9]+$/, + ); + expect(checkId("", "")).toMatch(/^check-[a-z0-9]+$/); + }); +}); + describe("fmtPct", () => { it("renders an em dash for null or NaN", () => { expect(fmtPct(null)).toBe("—"); diff --git a/lib/format.ts b/lib/format.ts index 1ef20a9..60fed5d 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -9,6 +9,62 @@ export function slugify(s: string): string { .replace(/^-+|-+$/g, ""); } +/** FNV-1a hash of a string as an unsigned 32-bit integer. */ +export function fnv1a(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** FNV-1a hash of a string as an unsigned 64-bit BigInt. */ +function fnv1a64(s: string): bigint { + const mask = (1n << 64n) - 1n; + let h = 14695981039346656037n; + for (let i = 0; i < s.length; i++) { + h ^= BigInt(s.charCodeAt(i)); + h = (h * 1099511628211n) & mask; + } + return h; +} + +/** + * Short, stable, URL-safe hash of a string (64-bit FNV-1a, base36). + * + * 64 bits keeps the birthday-collision probability negligible even for + * installations with millions of checks sharing a job slug. + */ +function shortHash(s: string): string { + return fnv1a64(s).toString(36); +} + +/** + * Canonical identity string for a check. A check's Grafana identity is the + * combination of job name + target (instance), so both must key it. + */ +export function checkIdentity(job: string, instance: string): string { + return `${job} ${instance}`; +} + +/** Readable slug base for a check: its job slug (target slug if job is empty). */ +function baseCheckId(job: string, instance: string): string { + return slugify(job) || slugify(instance) || "check"; +} + +/** + * Unique, deterministic public id for a check. + * + * The hash depends only on this check's own (job, target), so an id never + * changes because some *other* check was added, removed, or renamed. + * 64 bits makes an accidental collision (even among many targets under one job) + * negligibly unlikely. The slug leads so URLs stay scannable and sort/autocomplete by service name. + */ +export function checkId(job: string, instance: string): string { + return `${baseCheckId(job, instance)}-${shortHash(checkIdentity(job, instance))}`; +} + /** Format an uptime percentage (0–100). */ export function fmtPct(n: number | null): string { if (n == null || Number.isNaN(n)) return "—"; diff --git a/lib/mock.test.ts b/lib/mock.test.ts index 15e49f7..6653531 100644 --- a/lib/mock.test.ts +++ b/lib/mock.test.ts @@ -2,12 +2,29 @@ import { describe, expect, it } from "vitest"; import { mockOverview, mockSiteHistory } from "./mock"; import { WINDOW_KEYS } from "./types"; +const idOf = (name: string): string => + mockOverview().find((s) => s.name === name)!.id; + describe("mockOverview", () => { it("returns every fixture site", () => { const sites = mockOverview(); - expect(sites).toHaveLength(6); - expect(sites.map((s) => s.id)).toContain("pesacheck"); - expect(sites.map((s) => s.id)).toContain("african-drone"); + expect(sites).toHaveLength(8); + // Every id is the readable slug plus a stable hash suffix. + expect(sites.some((s) => s.id.startsWith("pesacheck-"))).toBe(true); + expect(sites.some((s) => s.id.startsWith("africandrone-"))).toBe(true); + }); + + it("gives every fixture a unique id, even when job slugs collide", () => { + const ids = mockOverview().map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + const publicApi = mockOverview().filter((s) => s.name.startsWith("Public")); + expect(publicApi).toHaveLength(2); + expect(publicApi[0].id).not.toBe(publicApi[1].id); + for (const s of publicApi) { + expect(s.id.startsWith("public-api-")).toBe(true); + // Each colliding id resolves back to its own check. + expect(mockSiteHistory(s.id, "24h")?.check.target).toBe(s.target); + } }); it("mirrors name/target into job/instance", () => { @@ -37,7 +54,7 @@ describe("mockOverview", () => { }); it("marks a down fixture down with null response times", () => { - const drone = mockOverview().find((s) => s.id === "african-drone")!; + const drone = mockOverview().find((s) => s.name === "africanDRONE")!; expect(drone.status).toBe("down"); for (const key of WINDOW_KEYS) { expect(drone.responseMs[key]).toBeNull(); @@ -45,7 +62,7 @@ describe("mockOverview", () => { }); it("reports up fixtures as operational with numeric response times", () => { - const pesacheck = mockOverview().find((s) => s.id === "pesacheck")!; + const pesacheck = mockOverview().find((s) => s.name === "PesaCheck")!; expect(pesacheck.status).toBe("up"); for (const key of WINDOW_KEYS) { expect(typeof pesacheck.responseMs[key]).toBe("number"); @@ -63,16 +80,17 @@ describe("mockSiteHistory", () => { }); it("returns history shaped for the requested window", () => { - const h = mockSiteHistory("pesacheck", "24h")!; + const id = idOf("PesaCheck"); + const h = mockSiteHistory(id, "24h")!; expect(h.window).toBe("24h"); - expect(h.check.id).toBe("pesacheck"); + expect(h.check.id).toBe(id); // 24h plans 48 buckets; bars and response points line up one-to-one. expect(h.bars).toHaveLength(48); expect(h.response).toHaveLength(48); }); it("keeps bar uptime fractions within 0–1", () => { - const h = mockSiteHistory("pesacheck", "7d")!; + const h = mockSiteHistory(idOf("PesaCheck"), "7d")!; for (const bar of h.bars) { expect(bar.uptime).toBeGreaterThanOrEqual(0); expect(bar.uptime).toBeLessThanOrEqual(1); @@ -80,14 +98,14 @@ describe("mockSiteHistory", () => { }); it("spaces bucket timestamps by the plan step", () => { - const h = mockSiteHistory("pesacheck", "24h")!; + const h = mockSiteHistory(idOf("PesaCheck"), "24h")!; const step = h.bars[1].t - h.bars[0].t; expect(step).toBe(1800); // 24h step expect(h.response.map((p) => p.t)).toEqual(h.bars.map((b) => b.t)); }); it("models a current incident on a down site", () => { - const h = mockSiteHistory("african-drone", "24h")!; + const h = mockSiteHistory(idOf("africanDRONE"), "24h")!; expect(h.status).toBe("down"); expect(h.responseMs).toBeNull(); // The last two buckets represent the ongoing outage: each is heavily @@ -100,8 +118,9 @@ describe("mockSiteHistory", () => { }); it("produces stable seeded data for the same id and window", () => { - const a = mockSiteHistory("pesacheck", "30d")!; - const b = mockSiteHistory("pesacheck", "30d")!; + const id = idOf("PesaCheck"); + const a = mockSiteHistory(id, "30d")!; + const b = mockSiteHistory(id, "30d")!; // Timestamps track the wall clock, but the seeded series must match. expect(a.bars.map((x) => x.uptime)).toEqual(b.bars.map((x) => x.uptime)); expect(a.response.map((x) => x.ms)).toEqual(b.response.map((x) => x.ms)); diff --git a/lib/mock.ts b/lib/mock.ts index b304300..0883f83 100644 --- a/lib/mock.ts +++ b/lib/mock.ts @@ -4,7 +4,7 @@ * in transparently once env is set — see lib/synthetics.ts. */ import { bucketPlan } from "./buckets"; -import { deriveStatus } from "./format"; +import { checkId, checkIdentity, deriveStatus, fnv1a } from "./format"; import { type CheckStatus, type MetricByWindow, @@ -17,7 +17,6 @@ import { } from "./types"; interface MockSite { - id: string; name: string; target: string; region: string; @@ -30,7 +29,6 @@ interface MockSite { const MOCK_SITES: MockSite[] = [ { - id: "pesacheck", name: "PesaCheck", target: "https://pesacheck.org", region: "Frankfurt", @@ -39,7 +37,6 @@ const MOCK_SITES: MockSite[] = [ currentlyUp: true, }, { - id: "code-for-africa", name: "Code for Africa", target: "https://codeforafrica.org", region: "Frankfurt", @@ -48,7 +45,6 @@ const MOCK_SITES: MockSite[] = [ currentlyUp: true, }, { - id: "sensors-africa", name: "sensors.AFRICA", target: "https://sensors.africa", region: "London", @@ -57,7 +53,6 @@ const MOCK_SITES: MockSite[] = [ currentlyUp: true, }, { - id: "academy-africa", name: "Academy", target: "https://academy.africa", region: "Frankfurt", @@ -66,7 +61,6 @@ const MOCK_SITES: MockSite[] = [ currentlyUp: true, }, { - id: "african-drone", name: "africanDRONE", target: "https://africandrone.org", region: "London", @@ -75,7 +69,6 @@ const MOCK_SITES: MockSite[] = [ currentlyUp: false, }, { - id: "the-continent", name: "The Continent", target: "https://thecontinent.org", region: "New York", @@ -83,8 +76,36 @@ const MOCK_SITES: MockSite[] = [ baseMs: 180, currentlyUp: true, }, + // Two checks whose job names collide after slug normalization ("Public API" + // and "Public-API" both slugify to "public-api") but target different URLs. + // They exercise the (job, instance) id disambiguation end to end. + { + name: "Public API", + target: "https://api.example.org", + region: "Frankfurt", + baseUptime: 99.9, + baseMs: 150, + currentlyUp: true, + }, + { + name: "Public-API", + target: "https://api.example.net", + region: "London", + baseUptime: 99.8, + baseMs: 160, + currentlyUp: true, + }, ]; +function idFor(site: MockSite): string { + return checkId(site.name, site.target); +} + +/** Stable RNG seed for a site, tied to its identity. */ +function seedFor(site: MockSite): string { + return checkIdentity(site.name, site.target); +} + /** Small deterministic PRNG so fixtures are stable across renders. */ function rng(seed: number): () => number { let s = seed >>> 0; @@ -97,17 +118,8 @@ function rng(seed: number): () => number { }; } -function hash(str: string): number { - let h = 2166136261; - for (let i = 0; i < str.length; i++) { - h ^= str.charCodeAt(i); - h = Math.imul(h, 16777619); - } - return h >>> 0; -} - function uptimeByWindow(site: MockSite): UptimeByWindow { - const r = rng(hash(site.id)); + const r = rng(fnv1a(seedFor(site))); const out = {} as UptimeByWindow; for (const key of WINDOW_KEYS) { // Shorter windows wobble a little more around the baseline. @@ -120,7 +132,7 @@ function uptimeByWindow(site: MockSite): UptimeByWindow { } function responseByWindow(site: MockSite): MetricByWindow { - const r = rng(hash(`${site.id}:resp`)); + const r = rng(fnv1a(`${seedFor(site)}:resp`)); const out = {} as MetricByWindow; for (const key of WINDOW_KEYS) { if (!site.currentlyUp) { @@ -138,7 +150,7 @@ export function mockOverview(): CheckStatus[] { return MOCK_SITES.map((site) => { const uptime = uptimeByWindow(site); return { - id: site.id, + id: idFor(site), name: site.name, target: site.target, job: site.name, @@ -155,12 +167,12 @@ export function mockSiteHistory( id: string, window: WindowKey, ): SiteHistory | null { - const site = MOCK_SITES.find((s) => s.id === id); + const site = MOCK_SITES.find((s) => idFor(s) === id); if (!site) return null; const plan = bucketPlan(window); const uptime = uptimeByWindow(site); - const r = rng(hash(`${site.id}:${window}`)); + const r = rng(fnv1a(`${seedFor(site)}:${window}`)); const bars: UptimeBucket[] = []; const response: ResponsePoint[] = []; @@ -195,7 +207,7 @@ export function mockSiteHistory( return { check: { - id: site.id, + id, name: site.name, target: site.target, job: site.name, diff --git a/lib/synthetics.test.ts b/lib/synthetics.test.ts index 1ac234d..267b18c 100644 --- a/lib/synthetics.test.ts +++ b/lib/synthetics.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { checkId } from "./format"; import { getOverview, getSiteHistory, listChecks } from "./synthetics"; +// The public id of the "Site A" fixture used across the overview/history tests. +const SITE_A_ID = checkId("Site A", "https://a.org"); + // Shared mock surface. `prom.instantQuery` / `prom.rangeQuery` are driven // per-test via mockImplementation; query strings are matched on the metric // tokens and range below. @@ -58,7 +62,7 @@ describe("listChecks", () => { expect(prom.instantQuery).toHaveBeenCalledWith("sm_info"); expect(checks).toEqual([ { - id: "alpha", + id: expect.stringMatching(/^alpha-[a-z0-9]+$/), name: "Alpha", target: "https://a.org", job: "Alpha", @@ -66,7 +70,7 @@ describe("listChecks", () => { region: undefined, }, { - id: "zebra", + id: expect.stringMatching(/^zebra-[a-z0-9]+$/), name: "Zebra", target: "https://z.org", job: "Zebra", @@ -75,6 +79,31 @@ describe("listChecks", () => { }, ]); }); + + it("gives every discovered (job, instance) pair a unique, slug-prefixed id", async () => { + prom.instantQuery.mockResolvedValue([ + sample({ job: "Public API", instance: "https://api.example.org" }, "1"), + sample({ job: "Public API", instance: "https://api.example.net" }, "1"), + sample({ job: "Public-API", instance: "https://api.example.io" }, "1"), + sample({ job: "Solo", instance: "https://solo.example.org" }, "1"), + ]); + + const checks = await listChecks(); + const ids = checks.map((c) => c.id); + + expect(checks).toHaveLength(4); + expect(new Set(ids).size).toBe(4); + + expect(checks.find((c) => c.job === "Solo")?.id).toMatch( + /^solo-[a-z0-9]+$/, + ); + + const colliding = checks.filter((c) => c.job !== "Solo"); + expect(colliding).toHaveLength(3); + for (const c of colliding) { + expect(c.id.startsWith("public-api-")).toBe(true); + } + }); }); describe("getOverview", () => { @@ -127,7 +156,7 @@ describe("getOverview", () => { expect(rest).toHaveLength(0); expect(site).toMatchObject({ - id: "site-a", + id: SITE_A_ID, name: "Site A", target: "https://a.org", region: "London", @@ -211,9 +240,9 @@ describe("getSiteHistory", () => { ), ); - const history = (await getSiteHistory("site-a", "24h"))!; + const history = (await getSiteHistory(SITE_A_ID, "24h"))!; - expect(history.check.id).toBe("site-a"); + expect(history.check.id).toBe(SITE_A_ID); expect(history.window).toBe("24h"); expect(history.status).toBe("up"); expect(history.responseMs).toBe(240); @@ -254,7 +283,7 @@ describe("getSiteHistory", () => { }); prom.rangeQuery.mockResolvedValue([{ metric: {}, values: [[100, "240"]] }]); - const history = (await getSiteHistory("site-a", "7d"))!; + const history = (await getSiteHistory(SITE_A_ID, "7d"))!; // The plotted line only reaches 240ms, but the summary reports the true // fixed-resolution extremes — so it can't shrink on a wider window. diff --git a/lib/synthetics.ts b/lib/synthetics.ts index 46ca7d3..41c7e0c 100644 --- a/lib/synthetics.ts +++ b/lib/synthetics.ts @@ -2,7 +2,7 @@ import "server-only"; import { unstable_cache } from "next/cache"; import { bucketPlan, responsePlan } from "./buckets"; import { config } from "./config"; -import { deriveStatus, slugify } from "./format"; +import { checkId, checkIdentity, deriveStatus } from "./format"; import { mockOverview, mockSiteHistory } from "./mock"; import { escapeLabel, instantQuery, rangeQuery } from "./prometheus"; import { @@ -28,11 +28,6 @@ function promRange(window: WindowKey): string { return window; // 24h / 7d / 30d / 1y are all valid PromQL durations } -/** Stable map key for a (job, instance) pair. */ -function key(job: string, instance: string): string { - return `${job} ${instance}`; -} - function matcher(check: Check): string { return `{job="${escapeLabel(check.job)}",instance="${escapeLabel(check.instance)}"}`; } @@ -49,10 +44,10 @@ export async function listChecks(): Promise { const job = s.metric.job ?? s.metric.check_name ?? ""; const instance = s.metric.instance ?? ""; if (!job || !instance) continue; - const k = key(job, instance); + const k = checkIdentity(job, instance); if (byKey.has(k)) continue; byKey.set(k, { - id: slugify(job || instance), + id: checkId(job, instance), name: job, target: instance, job, @@ -88,7 +83,7 @@ function toKeyedNumbers( for (const s of samples) { const v = Number(s.value[1]); if (Number.isFinite(v)) - out.set(key(s.metric.job ?? "", s.metric.instance ?? ""), v); + out.set(checkIdentity(s.metric.job ?? "", s.metric.instance ?? ""), v); } return out; } @@ -109,7 +104,7 @@ async function fetchOverview(): Promise { ); return checks.map((c) => { - const k = key(c.job, c.instance); + const k = checkIdentity(c.job, c.instance); const uptime = {} as MetricByWindow; const responseMs = {} as MetricByWindow; WINDOWS.forEach((w, i) => { diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..b83673d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2024", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,