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
72 changes: 72 additions & 0 deletions app/site/[id]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
13 changes: 12 additions & 1 deletion app/site/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,18 @@ export async function generateMetadata({
params: Promise<{ id: string }>;
}): Promise<Metadata> {
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({
Expand Down
12 changes: 12 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<job-slug>-<hash>`, 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/<id>` 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
Expand Down
44 changes: 38 additions & 6 deletions e2e/home.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 }) => {
Expand All @@ -22,23 +22,55 @@ 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 ({
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");
});
});
23 changes: 17 additions & 6 deletions e2e/site.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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" }),
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
54 changes: 54 additions & 0 deletions lib/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
barColor,
checkId,
deriveStatus,
fmtMs,
fmtPct,
Expand Down Expand Up @@ -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("—");
Expand Down
56 changes: 56 additions & 0 deletions lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use collision-resistant route IDs

When an installation has many checks sharing the same job slug (for example one job name monitoring many targets) or a crafted pair of targets, this 32-bit FNV suffix can collide, producing duplicate /site/<id> links and React keys; getSiteHistory() then resolves the first matching check, leaving the other target without an addressable detail page. Use a longer/collision-resistant digest or include an encoded target component so the public ID is actually unique for the (job, target) pair.

Useful? React with 👍 / 👎.

@maquchizi maquchizi Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the chance of a collision at 32-bit is "highly unlikely", moving to 64 bit makes it "practically impossible" at the cost of a longer (uglier?) hash. I think that's a fair trade off so I've made the change.

}

/** Format an uptime percentage (0–100). */
export function fmtPct(n: number | null): string {
if (n == null || Number.isNaN(n)) return "—";
Expand Down
Loading
Loading