*+*]:border-border min-w-0 [&>*+*]:border-t",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/components.tsx b/apps/web/src/content/mdx-components/demo/components.tsx
new file mode 100644
index 00000000..c0bb3b6b
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/components.tsx
@@ -0,0 +1,72 @@
+import {
+ StatusComponent,
+ StatusComponentDescription,
+ StatusComponentHeader,
+ StatusComponentHeaderLeft,
+ StatusComponentHeaderRight,
+ StatusComponentIcon,
+ StatusComponentStatus,
+ StatusComponentTitle,
+ StatusComponentUptime,
+} from "@openstatus/ui/components/blocks/status-component";
+import { StatusComponentGroup } from "@openstatus/ui/components/blocks/status-component-group";
+
+import { demo, getGroups, getMonitors } from "@/data/demo-data";
+
+import { Cell, CellBody, CellDescription, CellHeader, CellTitle } from "./cell";
+
+/** Monitors fill in from checks; external services are set by hand; both group. */
+export function ComponentsDemo() {
+ const monitors = getMonitors().length;
+ const external = demo.components.length - monitors;
+ return (
+
+
+ Components
+
+ {monitors} monitors · {external} external
+
+
+ {/* The group block pulls itself out by 12px; pad so it lands on the cell gutter. */}
+
+ {getGroups().map((group) => (
+
+ {group.items.map((c) => {
+ const external = "external" in c && c.external;
+ return (
+
+
+
+
+ {c.name}
+
+ {external ? "external" : "monitor"}
+
+
+
+ {external ? null : (
+
+ {c.uptime}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ ))}
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/import.tsx b/apps/web/src/content/mdx-components/demo/import.tsx
new file mode 100644
index 00000000..9703d083
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/import.tsx
@@ -0,0 +1,50 @@
+import { Button } from "@openstatus/ui/components/ui/button";
+import { Input } from "@openstatus/ui/components/ui/input";
+
+import { demo, formatNumber } from "@/data/demo-data";
+
+import {
+ Cell,
+ CellDescription,
+ CellFooter,
+ CellGrid,
+ CellGridItem,
+ CellHeader,
+ CellRow,
+ CellTitle,
+} from "./cell";
+
+/** Paste a key, preview the counts, confirm. Nothing is written before that. */
+export function ImportDemo() {
+ return (
+
+
+ Import from {demo.import.provider}
+ preview
+
+
+ API key
+
+
+
+ {demo.import.counts.map((row) => (
+
+ {row.label}
+ {formatNumber(row.value)}
+
+ ))}
+
+
+ Nothing is written until you confirm.
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/index.tsx b/apps/web/src/content/mdx-components/demo/index.tsx
new file mode 100644
index 00000000..96163faf
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/index.tsx
@@ -0,0 +1,56 @@
+import { AccessDemo } from "./access";
+import { AlertDemo } from "./alert";
+import { AssertionsDemo } from "./assertions";
+import { AuditDemo, StatusReportDemo } from "./audit";
+import { ComponentsDemo } from "./components";
+import { ImportDemo } from "./import";
+import { LogsDemo } from "./logs";
+import { MaintenanceDemo } from "./maintenance";
+import { MonitorDemo } from "./monitor";
+import { NotifyDemo } from "./notify";
+import { PrivateLocationDemo } from "./private-location";
+import { RegionsDemo } from "./regions";
+import { SlackAgentDemo } from "./slack-agent";
+import { StatusPageDemo } from "./status-page";
+import { SubscriptionsDemo } from "./subscriptions";
+import { TerminalDemo } from "./terminal";
+import { ThemesDemo } from "./themes";
+import { TimingDemo } from "./timing";
+import { TranslationsDemo } from "./translations";
+
+const demos = {
+ "status-page": StatusPageDemo,
+ alert: AlertDemo,
+ "slack-agent": SlackAgentDemo,
+ notify: NotifyDemo,
+ access: AccessDemo,
+ "status-report": StatusReportDemo,
+ audit: AuditDemo,
+ components: ComponentsDemo,
+ subscriptions: SubscriptionsDemo,
+ translations: TranslationsDemo,
+ themes: ThemesDemo,
+ import: ImportDemo,
+ maintenance: MaintenanceDemo,
+ terminal: TerminalDemo,
+ monitor: MonitorDemo,
+ regions: RegionsDemo,
+ assertions: AssertionsDemo,
+ "private-location": PrivateLocationDemo,
+ timing: TimingDemo,
+ logs: LogsDemo,
+} as const;
+
+export type DemoType = keyof typeof demos;
+
+/** A live product moment from `data/demo-data.ts`, picked by `type`. */
+export function Demo({ type }: { type: DemoType }) {
+ const Component = demos[type];
+ // MDX passes a string, so the key is only checked here.
+ if (!Component) throw new Error(`Unknown demo type "${type}"`);
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/logs.tsx b/apps/web/src/content/mdx-components/demo/logs.tsx
new file mode 100644
index 00000000..bd3eee05
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/logs.tsx
@@ -0,0 +1,67 @@
+import { getRegionInfo } from "@openstatus/regions";
+
+import { auditRow, demo, formatNumber } from "@/data/demo-data";
+import { cn } from "@/lib/utils";
+
+import {
+ Cell,
+ CellFooter,
+ CellLabel,
+ CellPhaseBar,
+ CellRow,
+ toneClass,
+} from "./cell";
+
+const time = auditRow("monitor.alert").time;
+
+// Same handshake cost in every region; the failing ones spend the rest on TTFB.
+const fixed = demo.timing.phases
+ .filter((t) => t.phase !== "TTFB")
+ .reduce((sum, t) => sum + t.ms, 0);
+
+function phasesOf(ms: number) {
+ return demo.timing.phases.map((t) => ({
+ ...t,
+ ms: t.phase === "TTFB" ? Math.max(ms - fixed, 0) : t.ms,
+ }));
+}
+
+// The timing bar is capped so the phases read as proportions, not as a full-width fill.
+const columns =
+ "grid grid-cols-[64px_52px_72px_minmax(0,1fr)] gap-x-3 sm:grid-cols-[64px_52px_72px_104px_minmax(0,240px)]";
+
+/** Every check kept: status, timing and body per region. */
+export function LogsDemo() {
+ return (
+
+
+ Time
+ Status
+ Latency
+ Region
+ Timing
+
+ {demo.regions.map((r) => {
+ const ok = r.status === 200;
+ return (
+
+ {time}
+ {r.status}
+
+ {formatNumber(r.ms)}
+ ms
+
+
+ {getRegionInfo(r.code).flag} {r.code}
+
+
+
+ );
+ })}
+
+ Headers and body kept for every failed check
+ export to OTLP
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/maintenance.tsx b/apps/web/src/content/mdx-components/demo/maintenance.tsx
new file mode 100644
index 00000000..85914c16
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/maintenance.tsx
@@ -0,0 +1,48 @@
+import {
+ StatusBannerContainer,
+ StatusBannerContent,
+} from "@openstatus/ui/components/blocks/status-banner";
+import {
+ StatusEventAffected,
+ StatusEventAffectedBadge,
+ StatusEventTimelineMaintenance,
+} from "@openstatus/ui/components/blocks/status-events";
+
+import { demo, formatNumber, getMaintenance } from "@/data/demo-data";
+
+import { Cell, CellBody, CellFooter } from "./cell";
+
+/** The banner the status page shows for a scheduled window, subscribers told first. */
+export function MaintenanceDemo() {
+ const maintenance = getMaintenance();
+ return (
+
+
+
+
+
+
+ {maintenance.affected.map((name) => (
+
+ {name}
+
+ ))}
+
+
+
+
+
+
+ Scheduled {Math.round(demo.maintenance.hoursFromNow / 24)} days ahead
+
+
+ {formatNumber(demo.subscribers.email)} subscribers notified at
+ scheduling
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/monitor.tsx b/apps/web/src/content/mdx-components/demo/monitor.tsx
new file mode 100644
index 00000000..880b3c3c
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/monitor.tsx
@@ -0,0 +1,245 @@
+import type React from "react";
+
+import { atTime, auditRow, demo, formatMs, hhmm } from "@/data/demo-data";
+import { cn } from "@/lib/utils";
+
+import {
+ Cell,
+ CellBody,
+ CellDescription,
+ CellFooter,
+ CellGrid,
+ CellGridItem,
+ CellHeader,
+ CellLabel,
+ CellMetric,
+ CellSwatch,
+ CellTitle,
+ chartClass,
+ type Tone,
+} from "./cell";
+
+const MINUTES = 60;
+const REGIONS = demo.regions.length;
+const failing = demo.regions.filter((r) => r.status !== 200);
+const healthy = demo.regions.filter((r) => r.status === 200);
+const FAILING = failing.length;
+const DEGRADED = healthy.filter(
+ (r) => r.ms > demo.monitor.degradedAfter,
+).length;
+const healthyMs = healthy.map((r) => r.ms).sort((a, b) => a - b);
+const p50 = healthyMs[Math.floor((healthyMs.length - 1) / 2)];
+const p95 = Math.max(...demo.regions.map((r) => r.ms));
+// The last three buckets are the spike.
+const spikeAt = MINUTES - 3;
+
+function noise(i: number, salt: number) {
+ return Math.abs(Math.sin(i * 12.9898 + salt) * 43758.5453) % 1;
+}
+
+const PHASES = ["DNS", "Connect", "TLS", "TTFB", "Transfer"] as const;
+
+// P95 per phase, per minute. The failing regions only move TTFB.
+const latency = Array.from({ length: MINUTES }, (_, i) => {
+ const spike = i >= spikeAt;
+ return [
+ 10 + noise(i, 1) * 6,
+ 32 + noise(i, 2) * 10,
+ 56 + noise(i, 3) * 12,
+ spike ? 3_800 + noise(i, 4) * 400 : 110 + noise(i, 4) * 60,
+ 9 + noise(i, 5) * 6,
+ ];
+});
+const latencyMax = Math.max(...latency.map((p) => p.reduce((a, b) => a + b)));
+
+const uptime = Array.from({ length: MINUTES }, (_, i) =>
+ i >= spikeAt
+ ? { ok: REGIONS - FAILING, error: FAILING }
+ : { ok: REGIONS, error: 0 },
+);
+
+const metrics: { label: string; value: string; tone?: Tone }[] = [
+ { label: "Uptime", value: demo.components[0].uptime, tone: "success" },
+ { label: "Failing", value: String(FAILING), tone: "destructive" },
+ { label: "Degraded", value: String(DEGRADED), tone: "warning" },
+ { label: "P50", value: formatMs(p50) },
+ { label: "P95", value: formatMs(p95) },
+ { label: "Regions", value: String(REGIONS) },
+];
+
+const windowEnd = hhmm(auditRow("status_report.create").time);
+const windowStart = new Date(
+ atTime(new Date(0), windowEnd).getTime() - 3_600_000,
+)
+ .toISOString()
+ .slice(11, 16);
+const alertAt = hhmm(auditRow("monitor.alert").time);
+
+const W = 480;
+const H = 96;
+
+/** Stacked areas, painted tallest cumulative first so each phase shows as its own band. */
+function areaPath(upTo: number) {
+ const step = W / (MINUTES - 1);
+ const top = latency
+ .map((phases, i) => {
+ const y = phases.slice(0, upTo + 1).reduce((a, b) => a + b);
+ return `${(i * step).toFixed(1)},${(H - (y / latencyMax) * H).toFixed(1)}`;
+ })
+ .join(" L");
+ return `M0,${H} L${top} L${W},${H} Z`;
+}
+
+function Chart({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ChartHeader(props: React.ComponentProps<"div">) {
+ return
;
+}
+
+function ChartLegend(props: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function ChartLegendItem({
+ className,
+ children,
+}: {
+ className: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function ChartPlot(props: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ChartAxis(props: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+/** The monitor overview in the dashboard: metrics, uptime per bucket, latency by phase. */
+export function MonitorDemo() {
+ const { monitor } = demo;
+ return (
+
+
+ {monitor.name}
+
+ {monitor.method} {monitor.url.replace("https://", "")} · every{" "}
+ {monitor.periodicity}
+
+
+
+ {metrics.map((m) => (
+
+ {m.label}
+ {m.value}
+
+ ))}
+
+
+
+
+ Uptime · last hour
+
+ Success
+ Error
+ Degraded
+
+
+
+
+ {uptime.map((b, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed series
+
+
+
+
+ ))}
+
+
+ {REGIONS}
+ {REGIONS / 2}
+ 0
+
+
+
+
+
+
+ Latency · P95 · last hour
+
+ {PHASES.map((label, i) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ {formatMs(latencyMax)}
+ {formatMs(latencyMax / 2)}
+ 0
+
+
+
+ {windowStart}
+ {windowEnd}
+
+
+
+
+ Dashboard · monitor overview
+
+ {FAILING} of {REGIONS} regions failing since {alertAt}
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/notify.tsx b/apps/web/src/content/mdx-components/demo/notify.tsx
new file mode 100644
index 00000000..d126e967
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/notify.tsx
@@ -0,0 +1,117 @@
+import {
+ atTime,
+ auditRow,
+ capitalize,
+ demo,
+ formatNumber,
+ getIncidentDay,
+} from "@/data/demo-data";
+
+import {
+ Cell,
+ CellBody,
+ CellDescription,
+ CellGrid,
+ CellGridItem,
+ CellHeader,
+ CellLabel,
+ CellTitle,
+} from "./cell";
+import {
+ SlackAppBadge,
+ SlackAttachment,
+ SlackAttachmentTitle,
+ SlackAuthor,
+ SlackAvatar,
+ SlackCode,
+ SlackField,
+ SlackFieldLabel,
+ SlackFields,
+ SlackFieldValue,
+ SlackLink,
+ SlackMessage,
+ SlackMessageBody,
+ SlackMessageContent,
+ SlackMessageMeta,
+ SlackTime,
+} from "./slack";
+
+const [, identified] = demo.incident.updates;
+
+/** One approval, three channels; the Slack card mirrors buildStatusReportBlocks. */
+export function NotifyDemo() {
+ const updatedAt = atTime(
+ getIncidentDay(),
+ auditRow("status_report.update", "→ identified").time,
+ ).toISOString();
+ return (
+
+
+
+ # {demo.company.slug}-{demo.company.customer.toLowerCase()}
+
+
+ Slack Connect · shared with {demo.company.customer}
+
+
+
+
+
+
+
+ openstatus
+
+ {identified.time}
+
+
+
+
+ {demo.incident.title} — {capitalize(identified.status)}
+
+
+
+ Status
+
+ {capitalize(identified.status)}
+
+
+
+ Page
+
+ {demo.company.domain}
+
+
+
+ {identified.message}
+
+ Affected
+
+ {demo.incident.affected.join(", ")}
+
+
+
+ Updated {updatedAt} · View details ·
+ Manage with /openstatus unsubscribe
+
+
+
+
+
+
+
+
+ Email
+ {formatNumber(demo.subscribers.email)} sent
+
+
+ RSS / Atom
+ feed updated
+
+
+ Slack Connect
+ {demo.subscribers.slackConnect} workspaces
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/private-location.tsx b/apps/web/src/content/mdx-components/demo/private-location.tsx
new file mode 100644
index 00000000..11813d5c
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/private-location.tsx
@@ -0,0 +1,40 @@
+import { demo } from "@/data/demo-data";
+
+import {
+ Cell,
+ CellDescription,
+ CellHeader,
+ CellPre,
+ CellRow,
+ CellTitle,
+ toneClass,
+} from "./cell";
+
+const command = `docker run -d --name openstatus-probe \\
+ --restart=always \\
+ -e OPENSTATUS_KEY=os_•••••••• \\
+ ${demo.privateLocation.image}`;
+
+/** One container inside the network; it shows up as one more region. */
+export function PrivateLocationDemo() {
+ return (
+
+
+ terminal
+
+ {demo.privateLocation.imageSize} image · arm64 and amd64
+
+
+ {command}
+ {demo.privateLocation.probes.map((probe) => (
+
+
+ {probe.name}{" "}
+ {probe.ip}
+
+ online · {probe.seen}
+
+ ))}
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/regions.tsx b/apps/web/src/content/mdx-components/demo/regions.tsx
new file mode 100644
index 00000000..4f122eb8
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/regions.tsx
@@ -0,0 +1,62 @@
+import { AVAILABLE_REGIONS, getRegionInfo } from "@openstatus/regions";
+
+import { auditRow, demo, formatNumber } from "@/data/demo-data";
+import { cn } from "@/lib/utils";
+
+import {
+ Cell,
+ CellDescription,
+ CellFooter,
+ CellGrid,
+ CellGridItem,
+ CellHeader,
+ CellTitle,
+ toneClass,
+} from "./cell";
+
+const providerName = { fly: "Fly.io", koyeb: "Koyeb", railway: "Railway" };
+const providers = [
+ ...new Set(AVAILABLE_REGIONS.map((r) => getRegionInfo(r).provider)),
+]
+ .flatMap((p) => (p === "private" ? [] : providerName[p]))
+ .join(" · ");
+
+/** Latency per region, not an average; the failing regions stand out. */
+export function RegionsDemo() {
+ return (
+
+
+ Regions · {demo.monitor.name}
+
+ last check {auditRow("monitor.alert").time} UTC
+
+
+
+ {demo.regions.map((region) => (
+
+
+
+ {getRegionInfo(region.code).flag} {region.code}
+
+
+ {formatNumber(region.ms)} ms
+
+
+
+ {region.city} · {region.cloud}
+
+
+ ))}
+
+
+ {demo.regions.length} regions selected
+ {providers}
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/slack-agent.tsx b/apps/web/src/content/mdx-components/demo/slack-agent.tsx
new file mode 100644
index 00000000..606bc60d
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/slack-agent.tsx
@@ -0,0 +1,120 @@
+import { Button } from "@openstatus/ui/components/ui/button";
+
+import {
+ auditRow,
+ capitalize,
+ demo,
+ formatNumber,
+ hhmm,
+} from "@/data/demo-data";
+
+import {
+ Cell,
+ CellBody,
+ CellDescription,
+ CellHeader,
+ CellKey,
+ CellKeyValues,
+ CellRow,
+ CellTitle,
+ CellValue,
+ toneClass,
+} from "./cell";
+import {
+ SlackAppBadge,
+ SlackAuthor,
+ SlackAvatar,
+ SlackLink,
+ SlackMessage,
+ SlackMessageBody,
+ SlackMessageContent,
+ SlackMessageMeta,
+ SlackTime,
+} from "./slack";
+
+const [investigating] = demo.incident.updates;
+const createdAt = hhmm(auditRow("status_report.create").time);
+
+/** Declare the incident from the thread: ask, review the draft, approve. */
+export function SlackAgentDemo() {
+ return (
+
+
+ {demo.company.slackChannel}
+ thread
+
+
+
+ {demo.company.oncall.initials}
+
+
+ {demo.company.oncall.name}
+ {createdAt}
+
+
+ @openstatus checkout API is returning 503s
+ from every EU region, US is fine. Open a status report?
+
+
+
+
+
+
+
+ openstatus
+
+ {createdAt}
+
+
+ |
+
+ Draft status report
+
+
+ Title
+ {demo.incident.title}
+ Status
+
+ {capitalize(investigating.status)}
+
+ Affected
+ {demo.incident.affected.join(", ")}
+ Message
+
+ {investigating.message}
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ openstatus
+
+ {createdAt}
+
+
+ Published to {demo.company.domain}.{" "}
+ {formatNumber(demo.subscribers.email)} subscribers notified. Reply
+ here to post the next update.
+
+
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/slack.tsx b/apps/web/src/content/mdx-components/demo/slack.tsx
new file mode 100644
index 00000000..4757a648
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/slack.tsx
@@ -0,0 +1,247 @@
+import type React from "react";
+
+import { cn } from "@/lib/utils";
+
+import type { Tone } from "./cell";
+
+/** One Slack message: `SlackAvatar` in the gutter, `SlackMessageContent` beside it. */
+export function SlackMessage({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+/** The app avatar reads "os" unless told otherwise. */
+export function SlackAvatar({
+ variant = "user",
+ className,
+ children = variant === "app" ? "os" : undefined,
+ ...props
+}: React.ComponentProps<"div"> & { variant?: "user" | "app" }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SlackMessageContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+/** Author line: `SlackAuthor`, optional `SlackAppBadge`, `SlackTime`. */
+export function SlackMessageMeta({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export function SlackAuthor({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export function SlackAppBadge({
+ className,
+ children = "APP",
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SlackTime({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export function SlackMessageBody({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+const attachmentBorder: Record
= {
+ success: "border-success",
+ warning: "border-warning",
+ destructive: "border-destructive",
+ info: "border-info",
+ muted: "border-border",
+};
+
+/** Block Kit attachment: colored left bar, stacked sections. */
+export function SlackAttachment({
+ tone = "muted",
+ className,
+ ...props
+}: React.ComponentProps<"div"> & { tone?: Tone }) {
+ return (
+
+ );
+}
+
+export function SlackAttachmentTitle({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+/** Two-up field grid; each `SlackField` holds a `SlackFieldLabel` and a `SlackFieldValue`. */
+export function SlackFields({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export function SlackField({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return ;
+}
+
+export function SlackFieldLabel({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export function SlackFieldValue({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+/** Inline link or @mention. */
+export function SlackLink({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export function SlackCode({
+ className,
+ ...props
+}: React.ComponentProps<"code">) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/status-blocks.tsx b/apps/web/src/content/mdx-components/demo/status-blocks.tsx
new file mode 100644
index 00000000..a441ecca
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/status-blocks.tsx
@@ -0,0 +1,23 @@
+import {
+ StatusBannerContainer,
+ StatusBannerIcon,
+ StatusBannerMessage,
+} from "@openstatus/ui/components/blocks/status-banner";
+import type { StatusType } from "@openstatus/ui/components/blocks/status.types";
+
+/** The page banner at demo size: icon and message on one line. */
+export function DemoBanner({
+ status,
+}: {
+ status: Exclude;
+}) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/status-page.tsx b/apps/web/src/content/mdx-components/demo/status-page.tsx
new file mode 100644
index 00000000..97428dfe
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/status-page.tsx
@@ -0,0 +1,147 @@
+import { StatusBanner } from "@openstatus/ui/components/blocks/status-banner";
+import { StatusBar } from "@openstatus/ui/components/blocks/status-bar";
+import {
+ StatusComponent,
+ StatusComponentBody,
+ StatusComponentFooter,
+ StatusComponentHeader,
+ StatusComponentHeaderLeft,
+ StatusComponentHeaderRight,
+ StatusComponentIcon,
+ StatusComponentTitle,
+ StatusComponentUptime,
+} from "@openstatus/ui/components/blocks/status-component";
+import { StatusComponentGroup } from "@openstatus/ui/components/blocks/status-component-group";
+import {
+ StatusPageFooter,
+ StatusPageFooterContent,
+ StatusPagePoweredBy,
+} from "@openstatus/ui/components/blocks/status-page-footer";
+import {
+ StatusPageHeader,
+ StatusPageHeaderActions,
+ StatusPageHeaderBrand,
+ StatusPageHeaderBrandButton,
+ StatusPageHeaderContent,
+ StatusPageHeaderNav,
+ StatusPageHeaderNavItem,
+} from "@openstatus/ui/components/blocks/status-page-header";
+import {
+ StatusPageMain,
+ StatusPageShell,
+} from "@openstatus/ui/components/blocks/status-page-shell";
+import {
+ StatusUpdates,
+ StatusUpdatesContent,
+ StatusUpdatesTrigger,
+} from "@openstatus/ui/components/blocks/status-updates";
+import Image from "next/image";
+
+import {
+ type DemoComponent,
+ demo,
+ getGroups,
+ getMonitors,
+ getStatusBarData,
+ worstStatus,
+} from "@/data/demo-data";
+
+import { Cell } from "./cell";
+import { SubscribeTabs } from "./subscribe";
+
+function MonitorCard({ component }: { component: DemoComponent }) {
+ const data = getStatusBarData(component);
+ return (
+
+
+
+ {component.name}
+
+
+ {component.uptime}
+
+
+
+
+
+
+
+
+ );
+}
+
+/** The live status page while a component is degraded: real blocks, no screenshot. */
+export function StatusPageDemo() {
+ const monitors = getMonitors();
+ const groups = getGroups(monitors);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Status
+
+
+ Events
+
+
+ Monitors
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* The group block pulls itself out by 12px; pad so it lands on the cell gutter. */}
+
+
+
+ {groups.map((group) => (
+
+ {group.items.map((component) => (
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+ openstatus
+
+
+ {demo.company.domain}
+
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/subscribe.tsx b/apps/web/src/content/mdx-components/demo/subscribe.tsx
new file mode 100644
index 00000000..736cff42
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/subscribe.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import {
+ StatusUpdatesJson,
+ StatusUpdatesRss,
+ StatusUpdatesSection,
+ StatusUpdatesSlack,
+ StatusUpdatesSsh,
+} from "@openstatus/ui/components/blocks/status-updates";
+import { Button } from "@openstatus/ui/components/ui/button";
+import { Checkbox } from "@openstatus/ui/components/ui/checkbox";
+import { Input } from "@openstatus/ui/components/ui/input";
+import { Label } from "@openstatus/ui/components/ui/label";
+import { Separator } from "@openstatus/ui/components/ui/separator";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@openstatus/ui/components/ui/tabs";
+import { useId, useState } from "react";
+
+import { feedUrl, getGroups, sshCommand } from "@/data/demo-data";
+
+/** The "Get updates" tabs as the status page renders them, no backend. */
+export function SubscribeTabs() {
+ return (
+
+
+ Email
+ Slack
+ RSS
+ JSON
+ SSH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Monitors start checked, external services unchecked; the group mirrors its items. */
+function groupChecked(items: readonly { name: string }[]) {
+ const checked = items.filter((c) => !("external" in c)).length;
+ if (checked === items.length) return true;
+ return checked === 0 ? false : "indeterminate";
+}
+
+function SubscribeEmailTab() {
+ const id = useId();
+ const [components, setComponents] = useState(false);
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/subscriptions.tsx b/apps/web/src/content/mdx-components/demo/subscriptions.tsx
new file mode 100644
index 00000000..c9563705
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/subscriptions.tsx
@@ -0,0 +1,19 @@
+import { demo, formatNumber } from "@/data/demo-data";
+
+import { Cell, CellDescription, CellHeader, CellTitle } from "./cell";
+import { SubscribeTabs } from "./subscribe";
+
+/** The "Get updates" popover as its own cell. */
+export function SubscriptionsDemo() {
+ return (
+
+
+ Get updates
+
+ {formatNumber(demo.subscribers.email)} email subscribers
+
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/terminal.tsx b/apps/web/src/content/mdx-components/demo/terminal.tsx
new file mode 100644
index 00000000..761ca159
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/terminal.tsx
@@ -0,0 +1,70 @@
+import {
+ atTime,
+ capitalize,
+ demo,
+ getIncidentDay,
+ getStatusBarData,
+ sshCommand,
+} from "@/data/demo-data";
+
+import { Cell, CellDescription, CellHeader, CellPre, CellTitle } from "./cell";
+
+const title = `${demo.company.name} Status`;
+const degradedLabel = demo.locales[0].systemStatus.degraded.long;
+const degraded = demo.components.filter((c) => c.status === "degraded");
+const identified = demo.incident.updates[1];
+
+// The SSH view, footer trimmed.
+const ssh = `$ ${sshCommand}
+
+ ▲ ${degradedLabel}
+ ${title}
+
+ ${demo.components.length} components · ${degraded.length} degraded`;
+
+/** The page for terminals and agents: SSH output and the markdown view. */
+export function TerminalDemo() {
+ const stamp = atTime(getIncidentDay(), identified.time).toLocaleString(
+ "en-US",
+ {
+ timeZone: "UTC",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ },
+ );
+ const component = demo.components[0];
+ const days = getStatusBarData(component).slice(-31);
+ const bar = days
+ .map((d) => (d.bar.every((s) => s.status === "success") ? "+" : "~"))
+ .join("");
+ const markdown = `# ${title}
+
+\`~\` **Degraded** · ${stamp} (GMT+0)
+
+## Active incidents
+
+- x **${demo.incident.title}** — ${capitalize(identified.status)} · affects: ${demo.incident.affected.join(", ")}
+
+## Components
+
+**${component.name}** — ${component.uptime} · \`${days.length - 1}d ago → today\`
+\`${bar}\``;
+ return (
+
+
+ terminal
+ ssh
+
+ {ssh}
+
+ {demo.company.domain}/.md
+ markdown
+
+ {markdown}
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/themes.tsx b/apps/web/src/content/mdx-components/demo/themes.tsx
new file mode 100644
index 00000000..9ce3c6ca
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/themes.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { THEME_KEYS, THEMES } from "@openstatus/theme-store";
+import { StatusBar } from "@openstatus/ui/components/blocks/status-bar";
+import {
+ StatusComponent,
+ StatusComponentBody,
+ StatusComponentFooter,
+ StatusComponentHeader,
+ StatusComponentHeaderLeft,
+ StatusComponentHeaderRight,
+ StatusComponentIcon,
+ StatusComponentStatus,
+ StatusComponentTitle,
+ StatusComponentUptime,
+} from "@openstatus/ui/components/blocks/status-component";
+import { Button } from "@openstatus/ui/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@openstatus/ui/components/ui/dropdown-menu";
+import { useState } from "react";
+
+import { demo, getMonitors, getStatusBarData } from "@/data/demo-data";
+
+import { Cell, CellBody, CellFooter, CellHeader, CellTitle } from "./cell";
+import { DemoBanner } from "./status-blocks";
+
+// The default theme is the page above; open on a store theme instead.
+const DEFAULT_THEME = "supabase";
+
+function scopedVars(vars: Record) {
+ return Object.entries(vars)
+ .map(([k, v]) => `${k}:${v}`)
+ .join(";");
+}
+
+function themeCss(id: string) {
+ const theme = THEMES[id];
+ // Matches the app's @custom-variant dark (&:is(.dark *)).
+ return `[data-demo-theme="${id}"]{${scopedVars(theme.light)}}.dark [data-demo-theme="${id}"]{${scopedVars(theme.dark)}}`;
+}
+
+/** Same blocks, re-skinned by the CSS tokens each store theme sets. */
+export function ThemesDemo() {
+ const [theme, setTheme] = useState(DEFAULT_THEME);
+ // The bar's hover card portals to body by default, outside the themed subtree.
+ const [scope, setScope] = useState(null);
+ const monitor = getMonitors()[0];
+ const data = getStatusBarData(monitor);
+ return (
+ <>
+
+
+
+ {demo.company.domain}
+
+
+
+
+
+
+ {THEME_KEYS.map((id) => (
+ setTheme(id)}>
+ {THEMES[id].name}
+
+ ))}
+
+
+
+
+
+ {/* Scope the theme below the body so the Cell's own rule keeps the page border color. */}
+
+
+
+
+
+
+ {monitor.name}
+
+
+
+ {monitor.uptime}
+
+
+
+
+
+
+
+
+
+
+
+
+ Domain {demo.company.domain} · verified
+ --success · --warning · --primary · --radius
+
+ |
+ >
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/timing.tsx b/apps/web/src/content/mdx-components/demo/timing.tsx
new file mode 100644
index 00000000..bd4fc0b9
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/timing.tsx
@@ -0,0 +1,64 @@
+import { demo, formatNumber } from "@/data/demo-data";
+import { cn } from "@/lib/utils";
+
+import {
+ Cell,
+ CellBody,
+ CellDescription,
+ CellFooter,
+ CellHeader,
+ CellTitle,
+ chartClass,
+} from "./cell";
+
+const total = demo.timing.phases.reduce((sum, t) => sum + t.ms, 0);
+const slowPhase = demo.timing.phases.find(
+ (t) => t.ms > demo.monitor.degradedAfter,
+);
+
+/** Where the 4 seconds went: one request, phase by phase. */
+export function TimingDemo() {
+ return (
+
+
+
+ {demo.monitor.name} · {demo.timing.region}
+
+ {formatNumber(total)} ms total
+
+
+ {demo.timing.phases.map((phase, i) => {
+ const pct = Math.max(1, Math.round((phase.ms / total) * 100));
+ const slow = phase.ms > demo.monitor.degradedAfter;
+ return (
+
+ {phase.phase}
+
+
+
+
+ {formatNumber(phase.ms)} ms
+
+
+ );
+ })}
+
+
+
+ {slowPhase?.phase ?? "Nothing"} above the degraded threshold
+
+
+ degraded after {formatNumber(demo.monitor.degradedAfter)} ms
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/demo/translations.tsx b/apps/web/src/content/mdx-components/demo/translations.tsx
new file mode 100644
index 00000000..2e97ac31
--- /dev/null
+++ b/apps/web/src/content/mdx-components/demo/translations.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import {
+ StatusComponent,
+ StatusComponentHeader,
+ StatusComponentHeaderLeft,
+ StatusComponentHeaderRight,
+ StatusComponentIcon,
+ StatusComponentStatus,
+ StatusComponentTitle,
+} from "@openstatus/ui/components/blocks/status-component";
+import {
+ StatusBlocksI18nProvider,
+ type StatusBlocksLabels,
+} from "@openstatus/ui/components/blocks/status-i18n";
+import { StatusLocaleSwitcher } from "@openstatus/ui/components/blocks/status-locale-switcher";
+import { defaultStatusBlocksLabels } from "@openstatus/ui/components/blocks/status.utils";
+import { useState } from "react";
+
+import { demo, getMonitors, worstStatus } from "@/data/demo-data";
+
+import { Cell, CellBody, CellHeader, CellTitle } from "./cell";
+import { DemoBanner } from "./status-blocks";
+
+const LOCALES = demo.locales.map((l) => ({ value: l.code, label: l.name }));
+// Open on a translated page, not the English default the reader already knows.
+const DEFAULT_LOCALE = "fr";
+
+function labelsFor(code: string): StatusBlocksLabels {
+ const locale = demo.locales.find((l) => l.code === code) ?? demo.locales[0];
+ return {
+ ...defaultStatusBlocksLabels,
+ systemStatus: {
+ ...defaultStatusBlocksLabels.systemStatus,
+ ...locale.systemStatus,
+ },
+ };
+}
+
+/** The same blocks, read through the i18n provider the status page mounts. */
+export function TranslationsDemo() {
+ const [locale, setLocale] = useState(DEFAULT_LOCALE);
+ const components = getMonitors().slice(0, 3);
+ return (
+
+
+ {demo.company.domain}
+
+
+
+
+
+ {components.map((c) => (
+
+
+
+
+ {c.name}
+
+
+
+
+
+
+ ))}
+
+
+ |
+ );
+}
diff --git a/apps/web/src/content/mdx-components/eyebrow.tsx b/apps/web/src/content/mdx-components/eyebrow.tsx
new file mode 100644
index 00000000..a7a4d42a
--- /dev/null
+++ b/apps/web/src/content/mdx-components/eyebrow.tsx
@@ -0,0 +1,20 @@
+import type React from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * Small label on the line before a heading (a timestamp, a step, a category);
+ * `globals.css` hands it the heading's top rule. Styled like `CellLabel`.
+ */
+export function Eyebrow({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/content/mdx-components/grid.tsx b/apps/web/src/content/mdx-components/grid.tsx
index 82afd5ab..030bf1ba 100644
--- a/apps/web/src/content/mdx-components/grid.tsx
+++ b/apps/web/src/content/mdx-components/grid.tsx
@@ -1,13 +1,20 @@
import type React from "react";
-import { cn } from "../../lib/utils";
+import { cn } from "@/lib/utils";
export function Grid({
cols = 2,
+ // Four columns collapse to a single column too early; step through two at `sm`.
+ sm = cols === 4 ? 2 : undefined,
+ variant = "bordered",
children,
className,
}: {
cols?: 1 | 2 | 3 | 4 | 5;
+ /** Intermediate column count between the single mobile column and `cols` at `md`. Defaults to 2 for `cols={4}`. */
+ sm?: 2 | 3;
+ /** `borderless` for a text + visual pair; the visual carries its own border. */
+ variant?: "bordered" | "borderless";
children: React.ReactNode;
className?: string;
}) {
@@ -28,6 +35,13 @@ export function Grid({
5: "[&>*]:border-t-0 [&>*:first-child]:border-t md:[&>*:nth-child(-n+5)]:border-t",
};
+ const smColsClass = { 2: "sm:grid-cols-2", 3: "sm:grid-cols-3" };
+ // Scoped to the sm-only range so the md rules above win once `cols` applies.
+ const smBorderClass = {
+ 2: "sm:max-md:[&>*:nth-child(-n+2)]:border-t sm:max-md:[&>*]:border-l-0 sm:max-md:[&>*:nth-child(2n+1)]:border-l",
+ 3: "sm:max-md:[&>*:nth-child(-n+3)]:border-t sm:max-md:[&>*]:border-l-0 sm:max-md:[&>*:nth-child(3n+1)]:border-l",
+ };
+
// Remove left border from all except first column (only on md+ screens)
const leftBorderClass = {
1: "",
@@ -37,16 +51,36 @@ export function Grid({
5: "md:[&>*]:border-l-0 md:[&>*:nth-child(5n+1)]:border-l",
};
+ if (variant === "borderless") {
+ return (
+ *]:min-w-0",
+ "[&>*>*:first-child]:!mt-0 [&>*>*:last-child]:!mb-0",
+ sm && smColsClass[sm],
+ colsClass[cols],
+ className,
+ )}
+ >
+ {children}
+
+ );
+ }
+
return (
*]:border-border [&>*]:border [&>*]:p-4",
+ "[&>*]:border-border [&>*]:min-w-0 [&>*]:border [&>*]:p-4",
// NOTE: remove extra margin from prose grid cells of first and last element
"[&>*>*:first-child]:!mt-0 [&>*>*:last-child]:!mb-0",
+ sm && smColsClass[sm],
colsClass[cols],
topBorderClass[cols],
leftBorderClass[cols],
+ sm && smBorderClass[sm],
className,
)}
>
diff --git a/apps/web/src/content/mdx-components/index.tsx b/apps/web/src/content/mdx-components/index.tsx
index a2bf3a1a..28d8f8d4 100644
--- a/apps/web/src/content/mdx-components/index.tsx
+++ b/apps/web/src/content/mdx-components/index.tsx
@@ -1,18 +1,23 @@
import { Suspense } from "react";
import { LatencyChartTable } from "../latency-chart-table";
+import { Actions } from "./actions";
import { Aside } from "./aside";
import { ButtonLink } from "./button-link";
import { Card, CardGrid, LinkCard } from "./card";
import { Code } from "./code";
import { CustomImage } from "./custom-image";
import { CustomLink } from "./custom-link";
-import { CustomerLogos } from "./customer-logos";
+import { Demo } from "./demo";
import { Details } from "./details";
+import { Eyebrow } from "./eyebrow";
import { Grid } from "./grid";
import { createHeading } from "./heading";
+import { LogoCloud } from "./logo-cloud";
import { Pre } from "./pre";
import { PricingTabs } from "./pricing-tabs";
+import { Quote } from "./quote";
+import { SrOnly } from "./sr-only";
import { MDXStatusPageExample } from "./status-page-example";
import { Subtle } from "./subtle";
import { Table } from "./table";
@@ -34,7 +39,11 @@ export const components = {
pre: Pre,
table: Table,
Grid,
- CustomerLogos,
+ LogoCloud,
+ Actions,
+ SrOnly,
+ Quote,
+ Demo,
Aside,
Card,
CardGrid,
@@ -46,5 +55,6 @@ export const components = {
StatusPageExample: MDXStatusPageExample,
PricingTabs,
Subtle,
+ Eyebrow,
Suspense: Suspense,
};
diff --git a/apps/web/src/content/mdx-components/logo-cloud.tsx b/apps/web/src/content/mdx-components/logo-cloud.tsx
new file mode 100644
index 00000000..451b8c9b
--- /dev/null
+++ b/apps/web/src/content/mdx-components/logo-cloud.tsx
@@ -0,0 +1,77 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+
+import Image from "next/image";
+
+import { customers } from "@/data/customers";
+import { getImageDimensions } from "@/lib/image-dimensions";
+
+import { CustomLink } from "./custom-link";
+import { Grid } from "./grid";
+
+function darkVariant(src: string) {
+ const dark = src.replace(/(\.[^.]+)$/, ".dark$1");
+ return existsSync(join(process.cwd(), "public", dark)) ? dark : undefined;
+}
+
+function Logo({
+ src,
+ alt,
+ height = 24,
+}: {
+ src: string;
+ alt: string;
+ height?: number;
+}) {
+ const size = getImageDimensions(src) ?? { width: 160, height: 32 };
+ const dark = darkVariant(src);
+ const className = "w-auto max-w-[140px] object-contain";
+ return (
+ <>
+
+ {dark ? (
+
+ ) : null}
+ >
+ );
+}
+
+/** Customer cells from `data/customers.ts`; a name in text until its logo lands. */
+export function LogoCloud({ limit = customers.length }: { limit?: number }) {
+ return (
+
+ {customers.slice(0, limit).map((customer) => (
+
+ {customer.logo ? (
+
+ ) : (
+ customer.name
+ )}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/content/mdx-components/quote.tsx b/apps/web/src/content/mdx-components/quote.tsx
new file mode 100644
index 00000000..a27d4d84
--- /dev/null
+++ b/apps/web/src/content/mdx-components/quote.tsx
@@ -0,0 +1,26 @@
+import { getCustomer } from "@/data/customers";
+
+import { CustomLink } from "./custom-link";
+import { Subtle } from "./subtle";
+
+/** Pull quote for a customer from `data/customers.ts`; drop it into a `Grid` cell. */
+export function Quote({ customer }: { customer: string }) {
+ const { name, quote, story, href } = getCustomer(customer);
+ if (!quote) throw new Error(`Customer "${name}" has no quote`);
+ return (
+
+
+ “{quote.text}”
+
+
+
+ {quote.name}{" "}
+ {quote.role}
+
+
+ {story ? "Read the story" : name}
+
+
+
+ );
+}
diff --git a/apps/web/src/content/mdx-components/sr-only.tsx b/apps/web/src/content/mdx-components/sr-only.tsx
new file mode 100644
index 00000000..a84f6db2
--- /dev/null
+++ b/apps/web/src/content/mdx-components/sr-only.tsx
@@ -0,0 +1,6 @@
+import type React from "react";
+
+/** A demo's text alternative: visually hidden on the page, read by screen readers, plain copy in the markdown. */
+export function SrOnly(props: { children?: React.ReactNode }) {
+ return
;
+}
diff --git a/apps/web/src/content/mdx-components/subtle.tsx b/apps/web/src/content/mdx-components/subtle.tsx
index 4b1adeea..423f2e9e 100644
--- a/apps/web/src/content/mdx-components/subtle.tsx
+++ b/apps/web/src/content/mdx-components/subtle.tsx
@@ -1,6 +1,6 @@
import type React from "react";
-import { cn } from "../../lib/utils";
+import { cn } from "@/lib/utils";
export function Subtle({ className, ...props }: React.ComponentProps<"span">) {
return
;
diff --git a/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx b/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx
index 017c3c76..aa959aad 100644
--- a/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx
+++ b/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx
@@ -189,4 +189,5 @@ Latency and response time are the raw measurements. These build on top of them:
- **[Run a one-off speed test](/play/checker)** — see the timing breakdown for your own URL from 28 regions, no account needed.
- **[Create your first monitor](/docs/tutorial/create-your-first-monitor)** — start tracking these metrics.
- **[Understanding uptime monitoring](/docs/concept/uptime-monitoring)** — broader monitoring concepts.
+- **[Response logs and data retention](/docs/concept/response-logs-and-retention)** — where the per-phase timing shows up for every check.
- **[HTTP monitor reference](/docs/reference/http-monitor)** — technical specifications.
diff --git a/apps/web/src/content/pages/docs/concept/response-logs-and-retention.mdx b/apps/web/src/content/pages/docs/concept/response-logs-and-retention.mdx
new file mode 100644
index 00000000..88592acb
--- /dev/null
+++ b/apps/web/src/content/pages/docs/concept/response-logs-and-retention.mdx
@@ -0,0 +1,63 @@
+---
+title: Response Logs and Data Retention
+description: What openstatus stores for every check, when the response body is kept, and how long each plan retains the data.
+category: Concepts
+---
+
+Every check a monitor runs produces one response log per region. The log is what you open when an alert fires and you need to know what the probe actually saw. This page describes what a log contains, when the response body is kept, and how long logs stay available.
+
+## What a response log contains
+
+| Field | Description |
+| --- | --- |
+| **Status** | `success`, `degraded` or `error`, as evaluated against your assertions and thresholds. |
+| **Status code** | The HTTP status code returned, when a response was received. |
+| **Latency** | Total request time in milliseconds. |
+| **Region** | The probe location the check ran from. |
+| **Timing** | Per-phase breakdown: DNS, connect, TLS, time to first byte, transfer. |
+| **Headers** | Every response header, as received. |
+| **Assertions** | The result of each configured assertion. |
+| **Message** | The error the probe hit, for example a timeout or a TLS failure. |
+| **Body** | The response body, kept only for failed and degraded checks (see below). |
+| **Trigger** | `cron` for scheduled checks, `api` for runs started from the dashboard, CLI or API. |
+
+The timing breakdown is the part worth reading first. It tells you whether the slowness came from DNS, the handshake or the server itself. The phases are explained in [latency vs response time](/docs/concept/latency-vs-response-time#the-request-in-phases-dns-tcp-tls-ttfb-transfer).
+
+## When the body is kept
+
+Headers, timing and assertion results are stored for every check. The response body is stored only when the check was not healthy:
+
+- **Failed checks** keep the body, so you can see the error page or JSON the endpoint returned.
+- **Degraded checks** keep the body, when the response was successful but slower than the [degraded threshold](/docs/reference/http-monitor#degraded).
+- **Healthy checks** drop the body. Storing it for every successful run would multiply the data without adding anything the status code and headers do not already say.
+
+The probe reads at most 10 MiB of the body. Anything beyond that is cut off and never stored. If you need the full body of every request, healthy or not, export the checks to your own backend with the [OpenTelemetry exporter](/docs/guides/how-to-export-metrics-to-otlp-endpoint).
+
+## Where to find logs
+
+- **Dashboard**: open a monitor and switch to the **Logs** tab. Filter by region, status, status code, trigger and time window, then click a row for the headers, timing, body and assertions.
+- **CLI**: `openstatus monitors logs
` lists recent logs. See the [CLI reference](/docs/reference/cli-reference).
+- **API and MCP**: list and fetch logs programmatically, or ask an agent connected through the [MCP server](/docs/reference/mcp-server).
+
+The Logs tab is available on paid plans. On the Hobby plan the monitor overview still shows per-region latency and uptime metrics, but individual logs cannot be browsed.
+
+## Retention
+
+Logs are kept for a fixed window per plan and then deleted. Retention counts from the time of the check.
+
+| Plan | Retention |
+| --- | --- |
+| Hobby | 14 days |
+| Starter | 3 months |
+| Pro | 12 months |
+| Scale | 24 months |
+
+Uptime percentages and status history on your status page are not affected by log retention. Those are computed and kept separately, so a status page can show a full year of uptime while the raw logs behind it have already aged out. See [uptime calculation](/docs/concept/uptime-calculation-and-values).
+
+To keep logs longer than your plan retains them, stream them to your own storage with the [OpenTelemetry exporter](/docs/guides/how-to-export-metrics-to-otlp-endpoint) as they happen. Retention cannot be extended retroactively.
+
+## Related resources
+
+- [HTTP monitor reference](/docs/reference/http-monitor): assertions, thresholds and retry settings that decide a check's status.
+- [Probes, locations and regions](/docs/concept/probes-and-locations): where checks run from.
+- [Export metrics to an OTLP endpoint](/docs/guides/how-to-export-metrics-to-otlp-endpoint): keep every request in your own observability stack.
diff --git a/apps/web/src/content/pages/docs/guides/how-to-connect-openstatus-to-your-agent.mdx b/apps/web/src/content/pages/docs/guides/how-to-connect-openstatus-to-your-agent.mdx
index 45cc94d8..4b693d57 100644
--- a/apps/web/src/content/pages/docs/guides/how-to-connect-openstatus-to-your-agent.mdx
+++ b/apps/web/src/content/pages/docs/guides/how-to-connect-openstatus-to-your-agent.mdx
@@ -125,7 +125,7 @@ If you granted write access, try drafting an incident — the agent shows you th
## Using an API key instead
-OAuth needs a browser. For CI, a remote box, or a committed config where you'd rather not sign in, pass an API key in the `x-openstatus-key` header instead. Create one in **Settings > API Tokens** with the scope you want (scopes are immutable on a key), then add the header where your agent puts it:
+OAuth needs a browser. For CI, a remote box, or a committed config where you'd rather not sign in, pass an API key in the `x-openstatus-key` header instead. Create one in **Settings > General > API Keys** with the scope you want (scopes are immutable on a key), then add the header where your agent puts it:
```bash
# Claude Code
diff --git a/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx b/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx
index abb6e476..59baf9c4 100644
--- a/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx
+++ b/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx
@@ -46,6 +46,8 @@ The available slash commands are:
- `/openstatus subscriptions` — show this channel's subscriptions
- `/openstatus help` — show this list (also the response to any unrecognised subcommand)
+A channel subscribed this way receives updates for the whole page. For component scoping, and for the other ways a status page can be followed (email, feeds, webhooks), see the [subscriber reference](/docs/reference/subscriber).
+
### 4. Verify the installation
In that channel, type:
diff --git a/apps/web/src/content/pages/docs/reference/http-monitor.mdx b/apps/web/src/content/pages/docs/reference/http-monitor.mdx
index 98f1639b..d93b12a7 100644
--- a/apps/web/src/content/pages/docs/reference/http-monitor.mdx
+++ b/apps/web/src/content/pages/docs/reference/http-monitor.mdx
@@ -283,3 +283,4 @@ Controls the visibility of monitor data on your public status page.
- **[Create your first monitor](/docs/tutorial/create-your-first-monitor)** — step-by-step tutorial on setting up a monitor.
- **[CLI reference](/docs/reference/cli-reference)** — manage monitors programmatically from the command line.
+- **[Response logs and data retention](/docs/concept/response-logs-and-retention)** — what each check stores, when the body is kept, and how long logs last per plan.
diff --git a/apps/web/src/content/pages/docs/reference/subscriber.mdx b/apps/web/src/content/pages/docs/reference/subscriber.mdx
index f475a710..7e704423 100644
--- a/apps/web/src/content/pages/docs/reference/subscriber.mdx
+++ b/apps/web/src/content/pages/docs/reference/subscriber.mdx
@@ -12,15 +12,27 @@ A subscriber in openstatus is an entity (typically a user or an integration) tha
- Stay informed about service health without actively monitoring the status page.
- Choose preferred notification channels for receiving updates.
-## Subscription process
+## Ways to subscribe
-Users typically subscribe to a status page's updates through a dedicated interface provided on the status page itself. The process involves:
+Every method delivers the same events: status report updates and scheduled maintenance. Pick the one that matches where the audience wants to receive them.
-1. **Entering an email address** — public self-subscription is email-only.
+| Method | Who sets it up | Where it is documented |
+| --- | --- | --- |
+| **Email** | The visitor, from the subscribe form on the status page | [Public self-subscription](#public-self-subscription) below |
+| **RSS / Atom / JSON feed** | The visitor, in a feed reader or script | [Feeds](#feeds) below |
+| **Slack channel via slash command** | Anyone in a Slack workspace with the openstatus app, from the channel itself | [Slack subscribers](#slack-subscribers) below |
+| **Email, webhook or Slack from the dashboard** | The page owner | [Adding subscribers from the dashboard](#adding-subscribers-from-the-dashboard) below |
+| **Slack Connect channel shared with a customer** | The page owner, scoped per customer | [Deliver status updates to customer Slack channels](/guides/slack-status-page-subscriptions) |
+
+## Public self-subscription
+
+Visitors subscribe through the subscribe form on the status page itself. The process involves:
+
+1. **Entering an email address** — the public form is email-only.
2. **Opt-in confirmation** — a verification email is sent, and the subscription only activates once the recipient confirms it.
3. **Scope selection (optional)** — where enabled, choosing which page components to be notified about instead of the whole page.
-Webhook and Slack subscriptions cannot be created from the public page. An operator adds those from the dashboard (see below).
+Webhook subscriptions are only created by the page owner from the dashboard. Slack channels can be subscribed either from the dashboard or from inside Slack with the slash command (see [Slack subscribers](#slack-subscribers)).
## Notification types received
@@ -46,7 +58,7 @@ Beyond public self-subscription, administrators can add subscribers directly fro
- **Email** — delivers updates to a contact address. By adding an email here you confirm the contact has consented to receive status updates; no confirmation email is sent.
- **Webhook** — POSTs each update to a URL. Slack and Discord URLs receive channel-native messages; any other URL receives a generic JSON payload.
-- **Slack** — posts each update straight into a Slack channel through the openstatus Slack app. The bot token is resolved from the workspace integration at send time and is never stored on the subscriber.
+- **Slack** — posts each update straight into a Slack channel through the openstatus Slack app. The bot token is resolved from the workspace integration at send time and is never stored on the subscriber. A channel can also subscribe itself with the slash command (see [Slack subscribers](#slack-subscribers)).
For every channel, you can optionally:
@@ -55,6 +67,46 @@ For every channel, you can optionally:

+## Slack subscribers
+
+A Slack subscriber posts every status report update and scheduled maintenance into one channel through the openstatus Slack app. There are two ways to create one.
+
+### From the dashboard
+
+The page owner adds a Slack subscriber from the subscribers section of the status page, picks the channel, and optionally scopes it to components. This is the path for [Slack Connect channels shared with customers](/guides/slack-status-page-subscriptions), where the owner decides which components each customer sees. It requires the [Slack agent](/docs/guides/how-to-setup-slack-agent) to be installed in your workspace.
+
+### From Slack
+
+Any channel in a Slack workspace that has the openstatus app installed can subscribe itself by running the slash command in that channel:
+
+```
+/openstatus subscribe https://acme.openstatus.dev
+```
+
+The URL is the public status page address, either the `*.openstatus.dev` subdomain or a custom domain. The subscription is created immediately without a confirmation step, the bot joins the channel if it can, and the channel receives updates for the whole page. Component scoping is not available from the command; use the dashboard for that.
+
+Related commands, all run from the channel:
+
+- `/openstatus subscriptions` — list the pages this channel is subscribed to.
+- `/openstatus unsubscribe [status-page-url]` — stop updates. The URL is optional when the channel follows exactly one page. Each Slack message also ends with a *Manage with `/openstatus unsubscribe`* hint.
+- `/openstatus help` — show the command list.
+
+Re-running `subscribe` on a channel that previously unsubscribed reactivates the existing subscription instead of creating a duplicate. Subscribing from Slack works against any page whose workspace is on a plan that includes subscribers; it does not require the channel's workspace to own the page.
+
+For installation and the natural-language incident commands, see [Set up the openstatus Slack agent](/docs/guides/how-to-setup-slack-agent).
+
+## Feeds
+
+Every status page exposes read-only feeds that need no subscription at all. Feed readers and scripts poll them directly:
+
+| Path | Format |
+| --- | --- |
+| `/feed/rss` | RSS 2.0 |
+| `/feed/atom` | Atom 1.0 |
+| `/feed/json` | JSON, the full page state including components |
+
+On a password-protected page, append `?pw=your-secret-password` to read them. Formats and fields are described in the [status page reference](/docs/reference/status-page#feeds).
+
## Webhook subscribers
A webhook subscriber receives a POST request for every status report and scheduled maintenance update. openstatus inspects the destination URL and emits a payload tailored to it.
@@ -243,6 +295,8 @@ export type WebhookPayload = z.infer;
## Related resources
-- **[Status page reference](/docs/reference/status-page)** — detailed information on managing and configuring status pages.
+- **[Status page reference](/docs/reference/status-page)** — detailed information on managing and configuring status pages, including the feed formats.
+- **[Set up the openstatus Slack agent](/docs/guides/how-to-setup-slack-agent)** — install the Slack app that powers Slack subscribers and the slash commands.
+- **[Deliver status updates to customer Slack channels](/guides/slack-status-page-subscriptions)** — component-scoped subscriptions for Slack Connect channels.
- **[Notification channels reference](/docs/reference/notification)** — technical specifications for the various notification delivery methods.
- **[Incident reference](/docs/reference/incident)** — information about incident creation and management.
diff --git a/apps/web/src/content/pages/guides/connect-openstatus-to-claude-code.mdx b/apps/web/src/content/pages/guides/connect-openstatus-to-claude-code.mdx
index 6d5400c6..c27c92b4 100644
--- a/apps/web/src/content/pages/guides/connect-openstatus-to-claude-code.mdx
+++ b/apps/web/src/content/pages/guides/connect-openstatus-to-claude-code.mdx
@@ -131,7 +131,7 @@ The agent resolves the page id via `list_status_pages`, presents the draft, and
## Headless setups: use an API key
-OAuth needs a browser on the same machine. In CI, on a remote box, or inside a container, pass an API key instead. Create one in **Settings > API Tokens** (scopes are immutable on a key) and add the header:
+OAuth needs a browser on the same machine. In CI, on a remote box, or inside a container, pass an API key instead. Create one in **Settings > General > API Keys** (scopes are immutable on a key) and add the header:
```bash
claude mcp add \
diff --git a/apps/web/src/content/pages/guides/connect-openstatus-to-claude-desktop.mdx b/apps/web/src/content/pages/guides/connect-openstatus-to-claude-desktop.mdx
index c10e84c9..0be0868a 100644
--- a/apps/web/src/content/pages/guides/connect-openstatus-to-claude-desktop.mdx
+++ b/apps/web/src/content/pages/guides/connect-openstatus-to-claude-desktop.mdx
@@ -96,7 +96,7 @@ Draft a status report on my "api" page: investigating elevated latency on the pa
## Alternative: API key with `mcp-remote`
-If you'd rather authenticate with an API key — a shared machine, or a key that outlives your user — Claude Desktop can bridge the server through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote). You'll need Node.js 18+ on your PATH. Create a key in **Settings > API Tokens** (scopes are immutable on a key), then add the server to `claude_desktop_config.json` (**Settings > Developer > Edit Config**):
+If you'd rather authenticate with an API key — a shared machine, or a key that outlives your user — Claude Desktop can bridge the server through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote). You'll need Node.js 18+ on your PATH. Create a key in **Settings > General > API Keys** (scopes are immutable on a key), then add the server to `claude_desktop_config.json` (**Settings > Developer > Edit Config**):
```json
{
diff --git a/apps/web/src/content/pages/guides/connect-openstatus-to-codex.mdx b/apps/web/src/content/pages/guides/connect-openstatus-to-codex.mdx
index 4c3541cc..d7423fa1 100644
--- a/apps/web/src/content/pages/guides/connect-openstatus-to-codex.mdx
+++ b/apps/web/src/content/pages/guides/connect-openstatus-to-codex.mdx
@@ -130,7 +130,7 @@ The agent resolves the page id via `list_status_pages`, presents the draft, and
## Headless setups: use an API key
-OAuth needs a browser on the same machine. In CI, on a remote box, or inside a container, pass an API key instead. Create one in **Settings > API Tokens** (scopes are immutable on a key) and add the header:
+OAuth needs a browser on the same machine. In CI, on a remote box, or inside a container, pass an API key instead. Create one in **Settings > General > API Keys** (scopes are immutable on a key) and add the header:
```bash
codex mcp add openstatus \
diff --git a/apps/web/src/content/pages/guides/connect-openstatus-to-opencode.mdx b/apps/web/src/content/pages/guides/connect-openstatus-to-opencode.mdx
index 37ad8315..0ad1f649 100644
--- a/apps/web/src/content/pages/guides/connect-openstatus-to-opencode.mdx
+++ b/apps/web/src/content/pages/guides/connect-openstatus-to-opencode.mdx
@@ -132,7 +132,7 @@ The agent resolves the page id via `list_status_pages`, presents the draft, and
## Headless setups: use an API key
-OAuth needs a browser on the same machine. In CI, on a remote box, or inside a container, pass an API key instead. Create one in **Settings > API Tokens** (scopes are immutable on a key) and add it as a header. opencode substitutes `{env:VAR}` in config values, so the secret stays out of the file:
+OAuth needs a browser on the same machine. In CI, on a remote box, or inside a container, pass an API key instead. Create one in **Settings > General > API Keys** (scopes are immutable on a key) and add it as a header. opencode substitutes `{env:VAR}` in config values, so the secret stays out of the file:
```json
{
diff --git a/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx b/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx
index 7fd85ddb..d91c0587 100644
--- a/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx
+++ b/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx
@@ -78,7 +78,7 @@ resource "openstatus_http_monitor" "api" {
name = "API Health Check"
url = "https://api.example.com/health"
periodicity = "1m"
- regions = ["iad", "ams", "syd"]
+ regions = ["fly-iad", "fly-ams", "fly-syd"]
status_code_assertions {
target = 200
diff --git a/apps/web/src/content/pages/guides/slack-status-page-subscriptions.mdx b/apps/web/src/content/pages/guides/slack-status-page-subscriptions.mdx
index 0e63637f..e67c65d8 100644
--- a/apps/web/src/content/pages/guides/slack-status-page-subscriptions.mdx
+++ b/apps/web/src/content/pages/guides/slack-status-page-subscriptions.mdx
@@ -101,7 +101,7 @@ Slack Connect subscriptions are a good fit when:
- **You already share a Slack Connect channel with the customer.** Adding status updates to a channel that already exists is almost always easier than spinning up a new notification surface.
- **You run a multi-tenant platform.** Per-customer routing, scoped to the components each customer depends on, avoids the noisy-neighbor problem where every customer sees every alert.
-For audiences that prefer other channels, openstatus also supports email and webhook subscriptions. Slack Connect is the right choice when the customer already operates in Slack and wants updates delivered where their team is.
+For audiences that prefer other channels, openstatus also supports email, webhook and feed subscriptions, and any Slack channel can follow a page on its own with `/openstatus subscribe `. That self-serve path delivers the whole page with no component scoping, so it suits internal channels rather than customer ones. All methods are compared in the [subscriber reference](/docs/reference/subscriber). Slack Connect is the right choice when the customer already operates in Slack and wants updates delivered where their team is.
## Managing Subscriptions Over Time
diff --git a/apps/web/src/content/pages/home.mdx b/apps/web/src/content/pages/home.mdx
index 226dcdfb..58229390 100644
--- a/apps/web/src/content/pages/home.mdx
+++ b/apps/web/src/content/pages/home.mdx
@@ -1,238 +1,301 @@
---
title: "Free & Open Source Status Page and Uptime Monitoring"
-hero: "Keep your status page in code. Let your agents update it."
+hero: "The status page that just works."
publishedAt: "2026-04-07"
-updatedAt: "2026-09-17"
+updatedAt: "2026-09-26"
author: "openstatus"
-description: "Open-source status page and uptime monitoring. Declare monitors in your repo with Terraform or the CLI. Let agents open and resolve status reports over MCP, every mutation audited. Hosted or self-hosted."
+description: "Open source status page and uptime monitoring. Your monitors update it, your subscribers hear it first, your auditor gets the trail. Live in five minutes."
category: "product"
faq:
- question: "What is openstatus?"
- answer: "Openstatus gives you a branded status page and uptime monitoring that's audit-ready out of the box. Set up status.yourcompany.com, connect your monitors, and start communicating incidents in minutes. It's open-source, self-hostable, and used by teams like Cal.com, WhiteBIT, and Documenso."
- - question: "Do I need a status page for SOC 2?"
- answer: "SOC 2's CC2.3 criteria requires you to demonstrate incident communication with external parties, but it doesn't prescribe a specific tool. That said, a status page is the fastest, most auditor-friendly way to satisfy that requirement. Every status report on openstatus is timestamped and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 10 minutes. Read more about [SOC 2 status pages](/use-case/compliance)."
- - question: "How does openstatus help with SOC 2 compliance?"
- answer: "Openstatus gives you everything an auditor needs to verify your incident communication process: a branded status page with a custom domain, incident history with timestamped status reports, subscriber notifications so stakeholders are proactively informed, maintenance windows for planned changes, and password protection for internal or client-specific pages. You can be SOC 2-ready in minutes, not weeks."
+ answer: "Openstatus is an open-source status page and uptime monitoring platform. Monitor from 28 regions, publish a branded status page on your own domain, and manage it from the dashboard, Slack, CLI, API, Terraform or an AI agent. It is used by teams like Cal.com, WhiteBIT, and Documenso, and can be self-hosted."
- question: "What does the free plan include?"
- answer: "The free plan includes one monitor, one status page with three page components, and a minimum check interval of 10 minutes. No credit card is required, and you can upgrade or cancel at any time."
+ answer: "The free Hobby plan includes one monitor, one status page with three page components, and a minimum check interval of 10 minutes. No credit card is required, and you can upgrade or cancel at any time."
+ - question: "Do I need a status page for SOC 2?"
+ answer: "SOC 2's CC2.3 criteria asks you to demonstrate incident communication with external parties. A public status page with timestamped, audited incident history is the shortest answer. Every status report on openstatus is timestamped and documented automatically, and every mutation is written to the audit log."
+ - question: "Can I self-host openstatus?"
+ answer: "Yes. Openstatus is AGPL-3.0 and can be self-hosted with Docker. You can also keep the hosted version and run private monitoring locations behind your firewall for internal services. The source code is on GitHub."
- question: "Who is behind openstatus?"
answer: "Openstatus is built by Thibault and Max, a bootstrapped two-person team building in public. We're profitable and self-funded, and we'll be here when your next audit comes around."
- - question: "What regions does openstatus monitor from?"
- answer: "Openstatus monitors from 28 regions worldwide: Europe (Amsterdam, Stockholm, Paris, Frankfurt, London), North America (Dallas, New Jersey, Los Angeles, San Jose, Chicago, Toronto), South America (São Paulo), Asia (Mumbai, Tokyo, Singapore), Africa (Johannesburg), and Oceania (Sydney)."
- - question: "Do you offer annual billing?"
- answer: "Yes. All paid plans are available with monthly or annual billing. Choose annual billing to get 2 months free: that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo). You can switch between billing cycles at any time."
- - question: "Can I self-host openstatus?"
- answer: "Yes. Openstatus is fully open source and can be self-hosted using its 8.5MB Docker image. You can also deploy private monitoring locations behind your firewall for internal services. The source code is available on GitHub."
- - question: "Does openstatus have an API?"
- answer: "Yes. Openstatus exposes a typed JSON-over-HTTP API powered by ConnectRPC, with a published OpenAPI spec at api.openstatus.dev/openapi. Every action in the dashboard (managing monitors, status pages, status reports, maintenance windows, and notification channels) is reachable from the API. The same API key works across the API, CLI, Node SDK, Terraform provider, and MCP server, and every mutation lands in the audit log."
- - question: "Can I manage openstatus from Claude or ChatGPT?"
- answer: "Yes. Openstatus ships a remote MCP (Model Context Protocol) server at api.openstatus.dev/mcp that connects Claude Desktop, ChatGPT, Cursor, and any MCP-compatible client to your workspace. The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every publishing tool requires the assistant to explicitly choose whether to notify subscribers, and every mutation is recorded in the audit log."
- - question: "Can I manage monitors as code?"
- answer: "Yes. Openstatus offers two ways to treat monitoring as code: a CLI with YAML config that lives in your repo (`openstatus monitors apply`), and a Terraform provider for HCL-managed infrastructure. Run `openstatus terraform generate` from the CLI to bootstrap an HCL file from an existing workspace."
---
-
-
-
-
+
+ Get started free
+ GitHub 9k+
+
+
+
+Free to start, with a 14-day Starter trial and no card needed. Paid plans from $30/mo.
+
-Free to start. Paid plans from $30/mo. Open source and self-hostable.
+
-
+
+
+A live status page for the fictional Pied Piper, mid-incident: a Degraded Performance banner, Checkout API degraded at 99.94% uptime, Webhooks, Dashboard and Docs operational, each with a 45-day uptime bar.
+
+
## Trusted by teams who ship transparency
-
+
-## Managing openstatus for humans and agents
+## One incident, start to finish
-Every action in the dashboard is reachable programmatically. One API key, four ways in:
+At 09:41 the Checkout API at Pied Piper starts returning 503s from Europe. Here is what openstatus does before the team has finished reading the first alert, and what is left behind six months later.
-- **[CLI](/tooling/cli)**: manage from your terminal
-- **[API](/tooling/api)**: typed HTTP endpoints with an [OpenAPI spec](https://api.openstatus.dev/openapi)
-- **[MCP server](/tooling/mcp-server)**: let Claude, ChatGPT, or Cursor run your monitoring
-- **[Terraform provider](/tooling/terraform)**: version monitors as HCL
+09:41
-Read more about our [tooling](/tooling).
+## Your monitor catches it first
-## The status page that closes enterprise deals
+
+
-Security questionnaires ask how you notify customers during an incident. openstatus answers that in one link: a branded page on your own domain, with timestamped incident history an auditor can read without asking you for screenshots.
+Four of the six regions the monitor runs from confirm the 503s, so the alert fires before the first support ticket arrives. It lands in Slack and PagerDuty with a link to the failing checks.
-Themes, custom domains, public or password-protected access, status reports, maintenance windows and subscriptions: see it all on [status pages](/status-page).
+- [Uptime monitoring](/uptime-monitoring)
+- [Notification channels](/docs/reference/notification)
+- [Check any URL from 28 regions](/play/checker)
+
+
+
-## Monitor from 28 regions: know before your customers do
+
-Monitor your endpoints from 28 regions across multiple clouds. Get alerted on Slack, Discord, PagerDuty, or email the moment something breaks. Your status page updates automatically, with no manual work during incidents.
+
-Read more about [uptime monitoring](/uptime-monitoring).
+The Slack alert at 09:41: Checkout API returned 503 from lhr, ams, cdg and koyeb_fra at 4,388 ms, with 4 of 6 regions failing the status-code assertion.
-## Try it before you sign up
+
-Check any URL's response time from every one of our 28 regions. No account, no credit card. It's the same probe network that powers your monitors.
+
+
-Global Speed Checker
+09:42
----
+## Know what to say before you say it
-
+
-**Status Page**
+
+
+
-
+Response logs for the 09:41 check, one row per region: the four European regions returned 503 in about 4 seconds, almost all of it TTFB, while Virginia and San Jose returned 200 in about 230 ms.
-[Visit our Theme Explorer](https://themes.openstatus.dev)
+
-**Dashboard**
+Open the failing checks. The European regions spend four seconds waiting on the edge while DNS and TLS are fine, and the US regions return 200 in under 250 ms. That is the sentence the status report needs, and the headers and body are kept in case the postmortem needs more.
+
+- [Response logs and retention](/docs/concept/response-logs-and-retention)
+- [Request phases: DNS, TCP, TLS, TTFB](/docs/concept/latency-vs-response-time#the-request-in-phases-dns-tcp-tls-ttfb-transfer)
+
+
+
+
+09:44
-
+## Declare it in Slack, or with your favorite agent
-[Go to Dashboard](https://app.openstatus.dev)
+
+
+
+Ask the openstatus agent in the incident thread. It drafts the status report, you approve it, and it goes live without anyone opening the dashboard. The same agent runs wherever you work: connect the MCP server to Claude, ChatGPT or Cursor and declare it from there.
+
+- [Slack agent](/docs/guides/how-to-setup-slack-agent)
+- [MCP server](/tooling/mcp-server)
+- [CLI](/tooling/cli)
+- [API](/tooling/api)
+
-
+
-## Frequently asked questions
+
-
+An engineer asks @openstatus in the incident thread to open a status report. The agent drafts "Elevated errors on Checkout API", the engineer approves, and 1,337 subscribers are notified.
-Openstatus gives you a branded status page and uptime monitoring that's audit-ready out of the box. Set up status.yourcompany.com, connect your monitors, and start communicating incidents in minutes.
+
-It's open-source, self-hostable, and used by teams like [Cal.com](https://status.cal.com), [WhiteBIT](https://status.whitebit.com), and [Documenso](https://status.documenso.com). Available as a [managed SaaS](https://app.openstatus.dev) or for [self-hosting](https://github.com/openstatushq/openstatus).
+
+
-
+09:52
-
+## Everyone hears it from you first
-SOC 2's CC2.3 criteria requires you to demonstrate incident communication with external parties, but it doesn't prescribe a specific tool. That said, a status page is the **fastest, most auditor-friendly** way to satisfy that requirement.
+
+
-Every status report on openstatus is **timestamped** and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 10 minutes.
+
-Read more about [SOC 2 status pages](/use-case/compliance).
+
-
+The 09:52 Identified update as it lands in a customer's Slack Connect channel, with the delivery summary: 1,337 emails sent, RSS and Atom updated, 3 Slack Connect workspaces notified.
-
+
-Openstatus gives you everything an auditor needs to verify your incident communication process:
+
+
-- **Branded status page** with custom domain
-- **Incident history** with timestamped status reports
-- **Subscriber notifications** so stakeholders are proactively informed
-- **Maintenance windows** for planned changes
-- **Password protection** for internal or client-specific pages
+Subscribers get the update by email, RSS and Slack Connect the moment it is approved. Support stops answering "is it down?" because the answer is already in the inbox.
-You can be SOC 2-ready in minutes, not weeks.
+- [Subscriptions](/docs/reference/subscriber)
+- [Reducing support tickets](/use-case/reduce-support-tickets)
-
+
+
-
+09:53
-The free plan includes **one monitor**, **one status page** (with three page components), and a minimum check interval of `10m`. Check the pricing table for a full comparison.
+## Only the right people see it
-No credit card required. Upgrade or cancel at any time.
+
+
-
+The public page shows the Checkout API as degraded. The internal page, behind a password and the office IP range, shows the failing regions and the rollback progress.
-
+- [Status page](/status-page)
+- [Password, magic link and IP restriction](/docs/reference/status-page#password-basic-auth)
+- [Status pages for enterprise sales](/use-case/enterprise-sales)
-Openstatus is built by [Thibault](https://bsky.app/profile/thibaultleouay.dev) and [Max](https://x.com/mxkaske), a bootstrapped two-person team building in public.
+
+
-We're profitable and self-funded, and we'll be here when your next audit comes around.
+
-Read more on [our about page](/about).
+
-
+Two pages side by side: the public status.piedpiper.dev shows Checkout API degraded, while internal.piedpiper.dev asks for a password and only admits the 203.0.113.0/24 range.
-
+
+
-
++6 months
+
+## The trail exists when the auditor asks
-
+
+
-Openstatus monitors from **28 regions worldwide** across all continents:
+Every step is logged: who declared it, from where, when each update shipped. When SOC 2 asks how you notify customers during an incident, you send one link.
-**Europe**
+- [Status pages for compliance](/use-case/compliance)
-Amsterdam 🇳🇱 | Stockholm 🇸🇪 | Paris 🇫🇷 | Frankfurt 🇩🇪 | London 🇬🇧
+
-**North America**
+
-Dallas 🇺🇸 | New Jersey 🇺🇸 | Los Angeles 🇺🇸 | San Jose 🇺🇸 | Chicago 🇺🇸 | Toronto 🇨🇦
+The incident's audit log, newest first: monitor.alert at 09:41:12, status_report.create at 09:44:30 by gilfoyle@piedpiper.dev via Slack, notification.send a second later, then the identified, monitoring and resolved updates. It exports as CSV or JSON.
-**South America**
+
-São Paulo 🇧🇷
+
+
-**Asia**
+
-Mumbai 🇮🇳 | Tokyo 🇯🇵 | Singapore 🇸🇬
+
-**Africa**
+The same incident six months later in the status page's events feed: four timestamped updates from investigating at 09:44 to resolved at 10:36.
-Johannesburg 🇿🇦
+
-**Oceania**
+
+
-Sydney 🇦🇺
+## In their words
-*Need a specific region?* Feel free to [contact us](mailto:ping@openstatus.dev) or join our [Discord](https://discord.gg/openstatus). We're always looking to expand our coverage!
+
+
-
+
-
+
+
-Yes. All paid plans are available with **monthly** or **annual** billing. Choose annual billing to get **2 months free**: that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo).
+
-You can switch between billing cycles at any time. Check the [pricing page](/pricing) for a full comparison.
+
+
-
+## For humans and agents
-
+Every action in the dashboard is reachable programmatically. One API key, four ways in.
-Yes. Openstatus is fully open source and can be self-hosted using its **8.5MB Docker image**. You can also deploy **private monitoring locations** behind your firewall to check internal services not exposed to the internet.
+- [CLI](/tooling/cli) for the terminal and CI, with `--json` output for scripts.
+- [API](/tooling/api) with typed endpoints, an OpenAPI spec and a Node SDK.
+- [MCP server](/tooling/mcp-server) so Claude, ChatGPT or Cursor can run it for you.
+- [Terraform](/tooling/terraform) to keep monitors, status pages and notifications as HCL.
-- [Self-hosting the full openstatus stack](https://www.openstatus.dev/docs/guides/self-hosting-openstatus/)
-- [Self-hosting the status page only](https://www.openstatus.dev/docs/guides/self-host-status-page-only/)
+## Flat pricing
-The source code is available on [GitHub](https://openstatus.dev/github).
+No per-seat fees. Every new workspace starts with a 14-day Starter trial, no card needed. [Open source](/github) and self-hostable under AGPL-3.0.
+
+| Plan | Price | Includes |
+| --- | --- | --- |
+| Hobby | $0 | 1 monitor, 1 status page, checks every 10 min |
+| Starter | $30/mo | 20 monitors, 1-min checks, custom domain, subscribers |
+| Pro | $100/mo | 50 monitors, 30-sec checks, all 28 regions, custom theme |
+| Scale | $500/mo | 500 components, white label, email auth and IP restriction |
+
+## Get started
+
+
+ Create your status page
+ Full pricing
+
+
+No account yet? [Check any URL's response time from 28 regions](/play/checker), no sign-up needed.
+
+## Frequently asked questions
+
+
+
+Openstatus is an open-source status page and uptime monitoring platform. Monitor from 28 regions, publish a branded status page on your own domain, and manage it from the dashboard, Slack, [CLI](/tooling/cli), [API](/tooling/api), [Terraform](/tooling/terraform) or an [AI agent](/tooling/mcp-server).
+
+It is used by teams like [Cal.com](https://status.cal.com), [WhiteBIT](https://status.whitebit.com), and [Documenso](https://status.documenso.com), and available as a [managed service](https://app.openstatus.dev) or for [self-hosting](/github).
-
+
-Yes. Openstatus exposes a typed **JSON-over-HTTP API** powered by [ConnectRPC](/blog/migrating-from-zod-openapi-to-connectrpc), with a published [OpenAPI spec](https://api.openstatus.dev/openapi).
+The free Hobby plan includes **one monitor**, **one status page** (with three page components), and a minimum check interval of `10m`. See the [pricing page](/pricing) for a full comparison.
-Every action in the dashboard (managing monitors, status pages, status reports, maintenance windows, and notification channels) is reachable from the API. The same API key works across the [API](/tooling/api), [CLI](/tooling/cli), Node SDK, [Terraform provider](/tooling/terraform), and [MCP server](/tooling/mcp-server), and every mutation lands in the audit log.
+No credit card required. Upgrade or cancel at any time.
-
+
-Yes. Openstatus ships a remote [MCP server](/tooling/mcp-server) at `api.openstatus.dev/mcp` that connects **Claude Desktop**, **ChatGPT**, **Cursor**, and any [Model Context Protocol](https://modelcontextprotocol.io) client to your workspace.
+SOC 2's CC2.3 criteria asks you to demonstrate incident communication with external parties. A public status page with **timestamped**, audited incident history is the shortest answer.
-The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every publishing tool requires the assistant to explicitly choose whether to notify subscribers (models can't quietly fan out an alert), and every mutation is recorded in the audit log under `actor_type = 'mcp'`.
+Every status report on openstatus is timestamped and documented automatically, and every mutation is written to the audit log. Read more about [SOC 2 status pages](/use-case/compliance).
-
+
+
+Yes. Openstatus is AGPL-3.0 and can be self-hosted with Docker. You can also keep the hosted version and run **private monitoring locations** behind your firewall for internal services.
+
+- [Self-hosting the full openstatus stack](/docs/guides/self-hosting-openstatus)
+- [Self-hosting the status page only](/docs/guides/self-host-status-page-only)
-Yes. Openstatus offers two ways to treat monitoring as code:
+The source code is on [GitHub](/github).
-- **YAML + [CLI](/tooling/cli)**: keep your monitor configuration in your repo and sync with `openstatus monitors apply`. Lighter to adopt, ships with a GitHub Action for CI/CD.
-- **[Terraform provider](/tooling/terraform)**: for teams already managing infrastructure in HCL. Same plan-and-apply lifecycle as the rest of your stack.
+
+
+
+
+Openstatus is built by [Thibault](https://bsky.app/profile/thibaultleouay.dev) and [Max](https://x.com/mxkaske), a bootstrapped two-person team building in public.
-Already have monitors in the dashboard? Run `openstatus terraform generate` from the CLI to bootstrap an HCL file with cross-references and import blocks. No hand-writing required.
+We're profitable and self-funded, and we'll be here when your next audit comes around. Read more on [our about page](/about).
diff --git a/apps/web/src/content/pages/product/status-page.mdx b/apps/web/src/content/pages/product/status-page.mdx
index f0e906aa..a6a72be5 100644
--- a/apps/web/src/content/pages/product/status-page.mdx
+++ b/apps/web/src/content/pages/product/status-page.mdx
@@ -1,11 +1,11 @@
---
title: "Status Page"
seo:
- title: "Free Status Page — Open Source, Custom Domain, Public or Private"
- description: "Create a free status page on your own domain in minutes. Open source and self-hostable. Public or private (password, magic link, IP allowlist), subscribers, maintenance windows, Slack agent."
+ title: "Free Status Page — Open Source, Custom Domain, Private"
+ description: "Create a free status page on your own domain in minutes. Open source, public or private, with subscribers, maintenance windows and a Slack agent."
hero: "Status pages your users actually trust"
publishedAt: "2025-11-10"
-updatedAt: "2026-09-17"
+updatedAt: "2026-09-26"
author: "Maximilian Kaske"
description: "Create a public or private status page in minutes. Show real-time uptime, incidents and maintenance on a fully branded page with a custom domain."
category: "Product"
@@ -32,183 +32,334 @@ faq:
answer: "The Slack agent lets you manage your status page directly from Slack using natural language. @mention @openstatus in any channel or thread to create incidents, post updates, and resolve reports — without leaving Slack. No slash commands required."
---
-## What is a status page?
+
+ Create your status page
+ Theme explorer
+
-A status page is a dedicated webpage where companies communicate the real-time health of their services to users. It shows which systems are operational, degraded, or experiencing outages and provides timestamped incident updates so users don't flood your support queue. Status pages build trust through transparency and satisfy compliance requirements like SOC 2 CC2.3.
+
+Free to start, with a 14-day Starter trial and no card needed. Paid plans from $30/mo.
+
-Status pages can be **public** (customer-facing) or **private** (internal teams or specific clients).
+
-A typical status page includes:
+
-- **Real-time operational status** for each service or component
-- **Timestamped incident updates** from investigating through resolution
-- **Subscriber notifications** via email, RSS, or webhook
-- **Uptime history** showing reliability over time
+Pied Piper's status page during a degraded Checkout API, built from the real status-page blocks: grouped components with 45-day uptime bars and a Get updates menu offering Email, Slack, RSS, JSON and SSH.
+
+
+
+## Trusted by teams who ship transparency
+
+
+
+## Why you need one
+
+
+
+
+**It deflects support load**
+
+During an outage every user asks the same question. A status page answers it once, for everyone, while your team fixes the thing. See [reducing support tickets](/use-case/reduce-support-tickets).
-
-
+
-Free to start. Paid plans from $30/mo.
+**Enterprise buyers ask for one**
-## Trusted by teams who ship transparency
+Security questionnaires ask how you notify customers during an incident. A public URL with a year of history is the strongest answer. See [status pages for enterprise sales](/use-case/enterprise-sales).
+
+
+
+
+**Auditors want the evidence**
+
+SOC 2 CC2.3 asks you to prove external communication. Every report is timestamped and archived, so the trail exists before they ask. See [status pages for compliance](/use-case/compliance).
+
+
+
+
+## Components, groups and external services
+
+
+
+
+Attach monitors and the uptime fills in from your checks. Add the services you depend on but do not run, and group everything the way your customers think about it.
+
+- [Page components reference](/docs/reference/page-components)
+- [Status page reference](/docs/reference/status-page)
+
+
+
+
+
+
+
+
+The components list: Checkout API, Webhooks, Dashboard and Docs synced from monitors, Stripe set by hand as an external service, grouped into Payments and Platform. A group takes the worst status of its members.
+
+
+
+
+
+
+## Subscriptions
+
+
+
+
+
+
+
+
+The Get updates popover with 1,337 email subscribers: an email form, the RSS URL for Slack's feed app, RSS and Atom URLs, the JSON feed at status.piedpiper.dev/feed/json and the SSH command.
+
+
+
+
+
+
+Users subscribe once and get every status report and maintenance by email, RSS, Atom or JSON. Teams pull the same feed into Slack.
+
+- [All subscription methods](/docs/reference/subscriber)
+- [Slack Connect updates](/guides/slack-status-page-subscriptions)
+
+
+
+
+## Translations
+
+
+
+
+Set a default locale and turn on the switcher. The page chrome and the canonical status copy are translated; your updates stay in the language you wrote them.
+
+English, French, German, Turkish, Hindi, Korean and Japanese today, with more from community contributions.
-
+- [Translate your status page](/docs/guides/how-to-translate-status-page)
-## Why do you need a status page?
+
+
+
+
+
+
-**It deflects support load.** During an outage, every user asks the same question: is it down, or is it me? Without somewhere to point them, that question arrives one ticket at a time while your team is already busy fixing the thing. A status page answers it once, for everyone. See [reducing support tickets](/use-case/reduce-support-tickets).
+The same page in French: a "Performances dégradées" banner, components labelled Opérationnel or Dégradé, and a switcher offering English, Deutsch, Français and 日本語.
-**Enterprise buyers ask for one.** Security questionnaires and vendor reviews routinely ask how you notify customers during an incident. "We email the affected accounts" is a weaker answer than a public URL with a year of incident history behind it. See [status pages for enterprise sales](/use-case/enterprise-sales).
+
+
+
+
-**Auditors want the evidence.** SOC 2's CC2.3 criteria asks you to demonstrate that you communicate incidents to external parties. Every status report on openstatus is timestamped and archived automatically, so the trail already exists when the auditor asks for it. See [status pages for compliance](/use-case/compliance).
+## Slack agent
-
+
-**Uptime Status Page**
+
-
+
+
+The Slack thread where @openstatus drafts "Elevated errors on Checkout API" with Approve, Approve & notify and Cancel buttons, then confirms it is live on status.piedpiper.dev.
+
+
-**Events Page**
+Mention @openstatus in any thread. It drafts the report, you approve, it publishes. Follow up in the same thread with "it's fixed" and it drafts the next update.
-
+Every action asks for confirmation before anything goes public, and every action is written to the audit log.
+
+- [Set up the Slack agent](/docs/guides/how-to-setup-slack-agent)
-## Why choose openstatus for your status pages?
+## Branded, on your domain
+
+
+
+
+Pick a theme from the store or override any CSS variable, separately for light and dark. Point status.yourdomain.com at it; the "Powered by" line goes away on Scale.
+
+- [Theme Store](https://themes.openstatus.dev)
+- [Custom theme variables](/docs/reference/status-page#custom-theme)
+
+
+
+
+
+
+
+
+The same status blocks re-skinned with a Theme Store theme: banner, component and uptime bar follow its CSS variables such as --success, --warning and --radius, in light and dark.
+
+
+
+
+
+
+## Internal and private pages
+
+
+
+
+
-We provide you with a good **mix of customization and opinionated options**.
+
-### Page Components
+Public and internal pages side by side: the public one lists all four components, the internal one shows a password prompt with an IP allowlist of 203.0.113.0/24 switched on.
-You can attach specific monitors to a status page. You can either populate the data from the **aggregated uptime** data **or manually** manage them. Read more [about uptime monitoring](/uptime-monitoring).
+
-Page components provide a **flexible structure** that supports both:
-- **Monitors**: Automatically synced with your uptime monitoring data
-- **External Services**: Manually managed components for third-party services or systems you don't directly monitor
+
+
-You can **group page components** by their services, locations, or any logical grouping, and they will be collapsible for better organization.
+Public by default. Protect a page with a password, magic-link login, or a CIDR allowlist so internal teams and named clients see more than the public does.
-### Links
+- [Password, magic link and IP restriction](/docs/reference/status-page#password-basic-auth)
+- [Status pages for enterprise sales](/use-case/enterprise-sales)
-Add a **Get in touch** button that points to a specific **website** link or a **`mailto:`** address.
+
+
-Include a **homepage** link to redirect the user to your page on clicking on the left-hand nav icon.
+## Maintenance windows
-### Subscriptions
+
+
-Allow your users to **subscribe** to your status page and automatically receive **updates** whenever you add a status report or maintenance.
+Schedule the window ahead of time. Subscribers are told when it is planned, the banner flips to maintenance while it runs, and affected components stop counting against uptime.
-We support following communication channels:
+- [Maintenance reference](/docs/reference/maintenance)
-- **Emails**
-- **RSS/Atom feeds**
-- **JSON**
+
+
-[Contact us](mailto:ping@openstatus.dev) if you are looking for a specific channel.
+
-### Translations
+
-Offer your status page in **multiple languages**. Set a default locale and enable a **locale switcher** so visitors can read updates in their preferred language. Currently supports English, French, German, Turkish, Hindi, Korean, and Japanese — with more languages coming from community contributions.
+A scheduled maintenance banner, "Database upgrade": a two-hour window three days out affecting Checkout API and Webhooks, with 1,337 subscribers notified when it was scheduled.
-### Slack Agent
+
-Manage incidents without leaving Slack. Install the **@openstatus** Slack agent and @mention it in any channel or thread to create, update, and resolve status reports using plain language - no slash commands, no tab switching.
+
+
-> **@openstatus** our API is returning 500 errors for about 10% of requests. Can you create an incident on the status page?
+## Import from another provider
-The agent reads the conversation, looks up your status pages and components, and drafts a status report. Before anything goes public it posts a confirmation card so you can review the title, status, and message. Choose to **Approve**, **Approve & Notify** (sends notifications to all subscribers), or **Cancel**.
+
+
-The agent is **thread-aware**: when you follow up in the same thread (_"we found the root cause"_ or _"it's fixed"_), it picks up the context and drafts the next update automatically. The entire incident lifecycle - from creation to resolution - can happen in a single Slack thread.
+
-### Import from Another Provider
+
-Already using Atlassian Statuspage, Better Stack, or Instatus? Import your entire setup -- components, component groups, incidents, maintenances, subscribers, and monitors -- in minutes. Open a status page, go to the **Components** tab, scroll down to the **Import** section, paste your API key, preview what will be imported, and confirm.
+The import preview for Atlassian Statuspage: 5 components, 2 groups, 37 status reports, 3 maintenances, 1,337 subscribers and 4 monitors, shown before anything is written.
-Read the [migration guides](/blog/import-from-statuspage-betterstack-instatus) for details on each provider.
+
-## Branded status pages
+
+
-Match your brand **appearance** with a theme from the **[Theme Store](https://themes.openstatus.dev)**, or contribute your own to the community.
+Paste an API key from Atlassian Statuspage, Better Stack or Instatus. Preview components, incidents, maintenances and subscribers before anything is written.
-Need something that stays private? The **Custom Theme** editor lets you override any CSS variable of the selected theme with your own values — separately for **light and dark mode**. Every variable you leave unset falls back to the base theme, so you can change a single color or restyle the entire page. Custom themes are included on Pro and Scale, and available as a **$20/mo add-on** on Starter. See the [supported variables](/docs/reference/status-page/#custom-theme).
+- [Import guide](/docs/guides/how-to-import-status-page)
-You can **define the values** you want to share with your users: response times and uptime from your monitors, or purely manual status report updates.
+
+
+
+## For terminals and agents
-You can create **custom domains** to keep the domain your users are used to.
+
+
-## Internal & private status pages
+The same page over SSH for people in a terminal, and as markdown for the agents and LLMs your customers point at it. No screenshots, no scraping.
-By default, your status page is public. For internal or client-specific pages, you can protect access using **password protection**, **magic link** authentication, or **IP restriction** (CIDR-based network allowlist) to control who can view your updates.
+- [SSH command](/docs/reference/status-page#ssh-command)
+- [Shadcn component registry](/registry)
-
-
+
+
+
+
+
+
+The page over SSH, `ssh pied-piper@ssh.openstatus.dev`, printing "Degraded Performance · Pied Piper Status · 5 components · 1 degraded", and the same page as markdown with a 30-day bar of + and ~ characters.
+
+
+
+
+
+
+## Get started
+
+
+ Create your status page
+
-Free to start. Paid plans from $30/mo.
+
Free to start, with a 14-day Starter trial and no card needed. Paid plans from $30/mo.
## Frequently asked questions
-
+
A status page is a dedicated webpage where companies communicate the real-time health of their services to users. It shows which systems are operational, degraded, or experiencing outages, and provides timestamped incident updates. Status pages reduce support tickets during incidents, build user trust through transparency, and satisfy compliance requirements like SOC 2.
-
+
A public status page is customer-facing and indexed — it deflects support tickets and answers vendor questionnaires. A private (internal) status page is for staff, contractors, or a single client, with access controlled by password protection, magic link authentication, or an IP allowlist. You can run both from the same workspace.
-
+
Monitors are automatically synced with your openstatus uptime monitoring data and update in real-time. External services are manually managed components for third-party dependencies or systems you don't directly monitor but want to report status for.
-
+
Yes, status pages support multiple languages (currently English, French, German, Turkish, Hindi, Korean, and Japanese). You can set a default locale and enable a locale switcher so visitors choose their preferred language. Translations are open source — you can contribute new languages by adding a locale to the shared registry and running the dev server to generate the translation file.
-
+
Yes, you can configure custom domains to host your status page on your own domain (e.g., status.yourcompany.com) instead of the default openstatus subdomain. This keeps the experience consistent with your brand.
-
+
Users can subscribe to receive updates when you post status reports or maintenance notices. We support email notifications, RSS/Atom feeds for feed readers, and JSON feeds for programmatic consumption. Subscribers are automatically notified when you publish updates.
-
+
Yes. Pick a community theme from the Theme Store or contribute your own. For a private look, the Custom Theme editor lets you override any CSS variable of the base theme for light and dark mode — included on Pro and Scale, or a $20/mo add-on on Starter. You can also define which data to share (uptime percentages, response times, or manual reports only).
-
+
Sign up, create a status page, give it a name and a slug, then add page components — either monitors synced from your uptime monitoring or external services you manage by hand. Point a custom domain at it if you want status.yourcompany.com, pick a theme, and publish. Most teams are live in under ten minutes on the free plan.
-
+
The free Hobby plan includes one status page with three components and no credit card. Paid plans start at $30/month for Starter (one status page, 20 components, custom domain, subscribers), $100/month for Pro, and $500/month for Scale. Annual billing gives you two months free. Extra status pages are $20/month each.
-
+
The Slack agent lets you manage your status page directly from Slack using natural language. @mention @openstatus in any channel or thread to create incidents, post updates, and resolve reports — without leaving Slack. No slash commands required.
diff --git a/apps/web/src/content/pages/product/tooling/api.mdx b/apps/web/src/content/pages/product/tooling/api.mdx
index 98229f55..5265724f 100644
--- a/apps/web/src/content/pages/product/tooling/api.mdx
+++ b/apps/web/src/content/pages/product/tooling/api.mdx
@@ -1,6 +1,10 @@
---
title: "API"
+hero: "API for monitors, status pages and incidents"
+seo:
+ title: "Status Page and Uptime Monitoring API — openstatus"
publishedAt: "2026-04-29"
+updatedAt: "2026-09-27"
author: "Maximilian Kaske"
description: "A typed JSON-over-HTTP API powered by ConnectRPC — every endpoint the dashboard uses, with a fully typed Node SDK on top."
category: "Product"
@@ -10,7 +14,7 @@ faq:
- question: "Is the API REST or RPC?"
answer: "It's ConnectRPC — JSON over HTTP, but with a typed RPC contract instead of REST conventions. Every method is a POST to /rpc/openstatus.v1./. You can call it from curl exactly like you'd call a REST endpoint."
- question: "How do I authenticate?"
- answer: "Pass the x-openstatus-key header on every request. Generate a key from Settings > API Tokens — the same key works for the CLI, Terraform provider, and MCP server."
+ answer: "Pass the x-openstatus-key header on every request. Generate a key from Settings > General > API Keys — the same key works for the CLI, Terraform provider, and MCP server."
- question: "Do you publish an OpenAPI spec?"
answer: "Yes. The full schema is browsable at api.openstatus.dev/openapi and is the source of truth for the Node SDK and any generated clients."
---
@@ -26,7 +30,7 @@ curl https://api.openstatus.dev/rpc/openstatus.v1.MonitorService/ListMonitors \
-d '{}'
```
-Generate a key from **Settings > API Tokens**.
+Generate a key from **Settings > General > API Keys**.
## What you can do
@@ -58,7 +62,7 @@ await client.statusReports.create({ /* ... */ });
Pass `x-openstatus-key` on every request. The token is the same one the CLI, Terraform provider, and MCP server share.
-Every mutation lands in the audit log under `actor_type = 'api'`, so you can trace any change back to a key, a user, and a transport.
+Every mutation lands in the audit log under `actor_type = 'apiKey'` with the key that made it, so you can trace any change back to a key and the user who created it.
## Other surfaces
@@ -72,25 +76,25 @@ Full schema and methods in the [API reference](https://api.openstatus.dev/openap
## Frequently asked questions
-
+
Use the Node SDK if you're writing TypeScript or JavaScript — it handles auth, retries, and types for every endpoint. Call the API directly via curl or your language's HTTP client if you're outside the JS ecosystem; ConnectRPC means responses are plain JSON over HTTP.
-
+
It's ConnectRPC — JSON over HTTP, but with a typed RPC contract instead of REST conventions. Every method is a POST to `/rpc/openstatus.v1./`. You can call it from curl exactly like you'd call a REST endpoint.
-
+
-Pass the x-openstatus-key header on every request. Generate a key from Settings > API Tokens — the same key works for the CLI, Terraform provider, and MCP server.
+Pass the x-openstatus-key header on every request. Generate a key from Settings > General > API Keys — the same key works for the CLI, Terraform provider, and MCP server.
-
+
Yes. The full schema is browsable at api.openstatus.dev/openapi and is the source of truth for the Node SDK and any generated clients.
diff --git a/apps/web/src/content/pages/product/tooling/cli.mdx b/apps/web/src/content/pages/product/tooling/cli.mdx
index aed15b3f..77e113ce 100644
--- a/apps/web/src/content/pages/product/tooling/cli.mdx
+++ b/apps/web/src/content/pages/product/tooling/cli.mdx
@@ -1,6 +1,10 @@
---
title: "CLI"
+hero: "Uptime monitoring and status pages from your terminal"
+seo:
+ title: "Uptime Monitoring CLI for Terminals and CI — openstatus"
publishedAt: "2026-04-29"
+updatedAt: "2026-09-27"
author: "Maximilian Kaske"
description: "Manage monitors, status pages, and incidents from your terminal — interactive for humans, scriptable for agents, and YAML-managed for monitoring as code."
category: "Product"
@@ -58,9 +62,9 @@ You get incident communication templates, maintenance announcement drafts, perio
## Auth & audit
-Authenticate with `openstatus login` (browser flow) or `OPENSTATUS_API_TOKEN` (CI). The token is the same one the API, Terraform provider, and MCP server share — generate it from **Settings > API Tokens**.
+Authenticate with `openstatus login` (browser flow) or `OPENSTATUS_API_TOKEN` (CI). The token is the same one the API, Terraform provider, and MCP server share — generate it from **Settings > General > API Keys**.
-Every mutation lands in the audit log under `actor_type = 'cli'`, so you can trace any change back to a key, a user, and a transport.
+Every mutation lands in the audit log under `actor_type = 'apiKey'` with the key that made it, so you can trace any change back to a key and the user who created it. Give the CLI its own key to keep its changes separable from other API callers.
## Other surfaces
@@ -70,29 +74,29 @@ Every mutation lands in the audit log under `actor_type = 'cli'`, so you can tra
## Reference
-Full command list and flags in the [CLI reference](https://www.openstatus.dev/docs/reference/cli-reference/).
+Full command list and flags in the [CLI reference](/docs/reference/cli-reference).
## Frequently asked questions
-
+
Yes. Every command supports --json output and structured errors, and required flags fail with a clear list of what's missing instead of hanging in a wizard. Combined with our agent skills, the CLI is the recommended way to give AI agents scriptable control over openstatus.
-
+
Yes. Set `OPENSTATUS_API_TOKEN`, use the `--json` flag for parseable output, and run `openstatus monitors apply` to sync your YAML config. We also publish a GitHub Action that wraps the CLI for synthetic tests.
-
+
Both treat monitoring as code. The YAML config is lighter to adopt and lives next to your repo. Terraform is the right choice when monitors are part of a larger HCL-managed infrastructure. Run `openstatus terraform generate` to bootstrap an HCL file from your existing workspace if you want to migrate.
-
+
Skills are prompt and command bundles installed into your local agent (Claude Code, etc.) that wrap the openstatus CLI. The MCP server is a remote endpoint that any MCP-compatible client can connect to. Skills are great for terminal-shaped workflows; MCP is great for chat-shaped workflows.
diff --git a/apps/web/src/content/pages/product/tooling/mcp-server.mdx b/apps/web/src/content/pages/product/tooling/mcp-server.mdx
index 5c9ea91d..618e1cf8 100644
--- a/apps/web/src/content/pages/product/tooling/mcp-server.mdx
+++ b/apps/web/src/content/pages/product/tooling/mcp-server.mdx
@@ -1,6 +1,11 @@
---
title: "MCP Server"
+hero: "Manage your status page from Claude, ChatGPT or Cursor"
+seo:
+ title: "openstatus MCP Server for Claude, ChatGPT and Cursor"
+ description: "Connect Claude, ChatGPT, Cursor or any MCP client to your openstatus workspace. OAuth sign-in, audit-logged changes, explicit control over notifications."
publishedAt: "2026-04-29"
+updatedAt: "2026-09-27"
author: "Maximilian Kaske"
description: "Connect Claude, ChatGPT, Cursor, and any other Model Context Protocol client to your workspace — sign in with OAuth, pick a workspace, and get audit-logged mutations with explicit notify semantics."
category: "Product"
@@ -47,7 +52,7 @@ The same tool registry powers the [in-app assistant](/blog/from-clickops-to-chat
The MCP server is an OAuth 2.1 authorization server. Connect from any client that implements the MCP authorization spec and you get a consent screen instead of a key to paste: pick the **workspace** and choose **Read-only** or **Read & write**. Tokens rotate automatically, and every connection is listed under **Settings > Integrations > Connected apps**, where it can be revoked with immediate effect.
-For CI and headless agents, the `x-openstatus-key` header still works — the same API key the CLI, API, and Terraform use, from **Settings > API Tokens**.
+For CI and headless agents, the `x-openstatus-key` header still works — the same API key the CLI, API, and Terraform use, from **Settings > General > API Keys**.
Both credentials carry a `read` or `write` scope. Read-only connections never see write tools in `tools/list` — they don't exist for that session. Write tools throw a clean error if a read-only credential somehow attempts them.
@@ -58,7 +63,7 @@ Every mutation lands in the audit log under `actor_type = 'mcp'`, so you can tra
- Want chat inside the dashboard instead of an external client? See the [in-app assistant](/blog/from-clickops-to-chatops).
- Want a terminal interface for AI agents (Claude Code, etc.)? See [skills](/tooling/cli#ai-skills) on the CLI page.
- Building an in-app integration? Call the [API](/tooling/api) directly.
-- Want Slack instead of Claude or ChatGPT? See the [Slack agent](/status-page#slack-agent).
+- Want Slack instead of Claude or ChatGPT? See the [Slack agent](/docs/guides/how-to-setup-slack-agent).
## Setup guides
@@ -68,29 +73,29 @@ Every mutation lands in the audit log under `actor_type = 'mcp'`, so you can tra
## Reference
-Full tool schema, error codes, and per-client config in the [MCP reference](/docs/reference/mcp-server/).
+Full tool schema, error codes, and per-client config in the [MCP reference](/docs/reference/mcp-server).
## Frequently asked questions
-
+
The MCP server exposes 19 read and write tools scoped to your workspace: list status pages, monitors, response logs, notifications, private locations, and audit logs; inspect a single monitor or response log; create, update, and resolve status reports; schedule maintenance windows. Every mutation writes to the audit log, and publishing tools require an explicit notify decision.
-
+
Skills are prompt and command bundles installed into your local agent (Claude Code, etc.) that wrap the openstatus CLI. The MCP server is a remote endpoint that any MCP-compatible client can connect to. Skills are great for terminal-shaped workflows; MCP is great for chat-shaped workflows.
-
+
No. The MCP server is an OAuth 2.1 authorization server: Claude, ChatGPT, Cursor, and other clients open a consent screen where you pick a workspace and read-only or read & write access, and tokens are issued and refreshed automatically. The `x-openstatus-key` header still works for CI and headless agents, with the same key the CLI, API, and Terraform use.
-
+
No. Every publishing tool requires the assistant to set notify: true | false explicitly — there's no default. Metadata-only edits like update_status_report can't notify at all. Every mutation lands in the audit log under actor_type = 'mcp', so you can see exactly what happened.
diff --git a/apps/web/src/content/pages/product/tooling/terraform.mdx b/apps/web/src/content/pages/product/tooling/terraform.mdx
index ca47adfa..1167fafc 100644
--- a/apps/web/src/content/pages/product/tooling/terraform.mdx
+++ b/apps/web/src/content/pages/product/tooling/terraform.mdx
@@ -1,6 +1,11 @@
---
title: "Terraform"
+hero: "Monitors and status pages as Terraform code"
+seo:
+ title: "Terraform Provider for Uptime Monitoring and Status Pages"
+ description: "Manage monitors, notifications and status pages as HCL with the openstatus Terraform provider. Same plan-and-apply lifecycle, one command to bootstrap."
publishedAt: "2026-04-29"
+updatedAt: "2026-09-27"
author: "Maximilian Kaske"
description: "Manage monitors, notifications, and status pages as HCL — same plan-and-apply lifecycle as the rest of your infrastructure, with one command to bootstrap from an existing workspace."
category: "Product"
@@ -12,7 +17,7 @@ faq:
- question: "How do I migrate an existing workspace into Terraform?"
answer: "Run openstatus terraform generate from the CLI. It fetches your full configuration and produces valid HCL with cross-references and import blocks — no hand-writing required."
- question: "Where do I get an API token?"
- answer: "Generate one from Settings > API Tokens, then set OPENSTATUS_API_TOKEN before running terraform plan. The same token works across the CLI, API, and MCP server."
+ answer: "Generate one from Settings > General > API Keys, then set OPENSTATUS_API_TOKEN before running terraform plan. The same token works across the CLI, API, and MCP server."
---
The [openstatus Terraform provider](https://registry.terraform.io/providers/openstatusHQ/openstatus/latest) treats monitoring as code: monitors, notifications, and status pages live in the same plan-and-apply lifecycle as the rest of your infrastructure.
@@ -28,11 +33,11 @@ terraform {
}
}
-resource "openstatus_monitor" "api" {
- name = "API"
- url = "https://api.example.com/health"
- frequency = "1m"
- regions = ["iad", "ams", "syd"]
+resource "openstatus_http_monitor" "api" {
+ name = "API"
+ url = "https://api.example.com/health"
+ periodicity = "1m"
+ regions = ["fly-iad", "fly-ams", "fly-syd"]
}
```
@@ -62,9 +67,9 @@ The output is a valid HCL configuration with cross-references between resources
## Auth & audit
-Set `OPENSTATUS_API_TOKEN` before running Terraform. The token is the same one the CLI, API, and MCP server share — generate it from **Settings > API Tokens**.
+Set `OPENSTATUS_API_TOKEN` before running Terraform. The token is the same one the CLI, API, and MCP server share — generate it from **Settings > General > API Keys**.
-Every mutation through the provider lands in the audit log under `actor_type = 'api'`, so you can trace any change back to a key, a user, and a transport.
+Every mutation through the provider lands in the audit log under `actor_type = 'apiKey'` with the key that made it, so you can trace any change back to a key and the user who created it. Give Terraform its own key to keep its changes separable from other API callers.
## Other surfaces
@@ -74,30 +79,30 @@ Every mutation through the provider lands in the audit log under `actor_type = '
## Reference
-Full resource list and schema in the [Terraform reference](https://www.openstatus.dev/docs/reference/terraform/).
+Full resource list and schema in the [Terraform reference](/docs/reference/terraform).
## Frequently asked questions
-
+
The Terraform provider is the right choice if monitoring is part of a larger HCL-managed infrastructure. The CLI's YAML config is lighter to adopt and ships with openstatus terraform generate to bootstrap an HCL file from your existing workspace if you want to migrate.
-
+
Monitors, notification channels, status pages, status page subscribers, and maintenance windows — every primary entity in the dashboard. See the Terraform reference for the full list and schema.
-
+
Run openstatus terraform generate from the CLI. It fetches your full configuration and produces valid HCL with cross-references and import blocks — no hand-writing required.
-
+
-Generate one from Settings > API Tokens, then set `OPENSTATUS_API_TOKEN` before running terraform plan. The same token works across the CLI, API, and MCP server.
+Generate one from Settings > General > API Keys, then set `OPENSTATUS_API_TOKEN` before running terraform plan. The same token works across the CLI, API, and MCP server.
diff --git a/apps/web/src/content/pages/product/uptime-monitoring.mdx b/apps/web/src/content/pages/product/uptime-monitoring.mdx
index d84b5c52..d6e1fc1c 100644
--- a/apps/web/src/content/pages/product/uptime-monitoring.mdx
+++ b/apps/web/src/content/pages/product/uptime-monitoring.mdx
@@ -1,11 +1,11 @@
---
title: "Uptime Monitoring"
publishedAt: "2025-11-10"
-updatedAt: "2026-09-17"
+updatedAt: "2026-09-26"
seo:
- title: "Open Source Uptime Monitoring — Free, 28 Regions, Alerts in Seconds"
- description: "Free & open source uptime monitoring for websites and APIs. Checks from 28 regions every 30s, alerts to Slack, Discord, PagerDuty and email. Monitoring as code with Terraform."
-hero: "Open source uptime monitoring for websites, APIs and services"
+ title: "Free Open Source Uptime Monitoring from 28 Regions"
+ description: "Free, open source uptime monitoring for websites and APIs. Checks from 28 regions every 30s, alerts to Slack, PagerDuty and email, monitoring as code."
+hero: "Uptime monitoring for websites, APIs and services"
author: "Thibault Le Ouay Ducasse"
description: "Monitor your websites, APIs and services from 28 regions. Get alerted the moment a check fails an assertion or exceeds your threshold."
category: "Product"
@@ -40,297 +40,319 @@ faq:
answer: "Yes, you can deploy as many private location probes as needed across different networks, VPCs, or regions. Each gets its own API key and appears as a separate monitoring region in your dashboard. The Docker image is only 8.5MB and supports ARM64 and AMD64."
---
-## Why is uptime monitoring important?
+
+ Create your monitor
+ Global speed checker
+
-Uptime monitoring is the practice of continuously asking one question on your users' behalf: is this service working right now? Instead of waiting for a customer to report a problem, checks run on a schedule from outside your own network and tell you the moment the answer changes.
+
+Free to start, with a 14-day Starter trial and no card needed. Paid plans from $30/mo.
+
-It answers three things a dashboard inside your own infrastructure cannot:
+
-1. **Is my service reachable?** Can users actually get to it, from where they are?
-2. **Is it fast enough?** Are response times inside the range you promised?
-3. **Is it correct?** Is it returning the data you expect, not just a 200?
+
-That external vantage point is the whole point. If your service runs on the same infrastructure as your monitoring, an outage that takes down one takes down the other — and you find out from your customers instead.
+The monitor overview for Checkout API, checked every minute: 99.94% uptime, 4 of 6 regions failing, P50 231 ms, P95 4.39 s, with an uptime chart per minute and a P95 latency chart stacked by phase where TTFB jumps to about 4 seconds.
+
+
+
+## Trusted by teams who ship reliability
+
+
+
+## How it works
+
+
+
+
+**1. Declare**
+
+
URL, method, headers, body and schedule. In the dashboard, the CLI or Terraform.
-
-
+
-Free to start. Paid plans from $30/mo. Open source and self-hostable.
+**2. Probe**
-## Trusted by teams who ship reliability
+Checkers in 28 regions run the request and record status, timing and body.
-
+
+
-## How uptime monitoring works
+**3. Assert**
-```
-+----------------+
-| Service to be |
-| Monitored |
-+----------------+
- ▲
- |
- | (Network Latency/Failures)
- |
-+-----+-------+ +-----+-------+ +-----+-------+
-| Monitoring | | Monitoring | | Monitoring |
-| Node (USA) | | Node (EU) | | Node (Asia) |
-+-----+-------+ +-----+-------+ +-----+-------+
- | | |
- |---------------|-----------------|
- ▼ ▼ ▼
-+------------------------------------------------+
-| openstatus |
-| |
-| - Sends automated requests (e.g., pings or |
-| HTTP checks) from all nodes at set intervals |
-| - Records response time and success/failure |
-| - Compares results from different nodes |
-| - If a failure or a slow response is detected, |
-| it triggers an alert. |
-+------------------------------------------------+
- |
- | (Alerts: Email, Slack, etc.) 🔔
- |
-+-----+-----+
-| Your Team |
-+-----------+
-```
+Status code, headers, body and latency thresholds decide success, degraded or failed.
+
+
+
+
+**4. Alert and publish**
+
+Notify Slack, PagerDuty or email. The status page reflects it without manual work.
+
+
+
+## 28 regions, three clouds
-
+
-**Uptime Dashboard**
+Probes run on Fly.io, Koyeb and Railway, so a provider incident never blinds you. Pick the regions your customers are in and see latency per region, not an average.
-
+- [Check any URL from every region](/play/checker)
-**Response Logs**
+
-
+
+
+Per-region latency for the last check: London 4,388 ms, Amsterdam 4,102 ms, Paris 4,051 ms and Frankfurt 3,970 ms, all 503; Virginia 231 ms and San Jose 244 ms, both 200. 6 of the 28 regions are selected.
+
+
-## Why choose openstatus for uptime monitoring?
+## API monitoring with assertions
+
+
+
+
+
+
+
+
+The request configuration and last result: a Bearer authorization header, assertions on status code 200 (failed, got 503), content-type contains json (passed) and body contains session_id (failed), degraded at 1,000 ms and timeout at 5,000 ms.
+
+
+
+
+
+
+Any method, custom headers, a request body. Assert on status code, header and body, and set degraded and timeout thresholds so slow counts as a problem before down does.
-### Global Monitoring
+- [HTTP monitor reference](/docs/reference/http-monitor)
+
+
+
+
+## Alerts where you already are
+
+
+
+
+A failed assertion from one region is noise; from half your regions it is an alert. Route it to Slack, Discord, PagerDuty, Opsgenie, email, SMS or a webhook, with the failing requests attached.
+
+Telegram, Google Chat, Microsoft Teams, WhatsApp, Grafana OnCall and ntfy are there too.
+
+- [Notification channels](/docs/reference/notification)
+
+
+
-You can monitor your services from multiple regions around the world. We currently have our probes deployed in **28 regions**.
+
-Check all regions in the [global speed checker](/play/checker).
+
-### Multi Cloud
+The alert as it reaches Slack, PagerDuty, email and a webhook: "Checkout API is failing", 503 Service Unavailable from four regions, "Expected status code 200, received 503", with a link to the failing checks.
-You should avoid monitoring your services from the same cloud providers you use for those services. That's why our probes are deployed across three different cloud providers:
+
-- **Fly.io**
-- **Koyeb**
-- **Railway**
+
+
-### API Monitoring
+## Where the time went
-Monitor any HTTP/HTTPS endpoint including REST APIs, GraphQL APIs, webhooks, and third-party service endpoints. openstatus supports all HTTP methods and custom headers for authentication.
+
+
-#### Assertions
+
-Validating your response can be important. You can do **status code**, **header** or **body text** assertions. Read more [in the docs](https://www.openstatus.dev/docs/reference/http-monitor/#assertions).
+
-#### Thresholds
+The phase breakdown of the London check: DNS 12 ms, Connect 38 ms, TLS 61 ms, TTFB 4,265 ms, Transfer 12 ms. Only TTFB is above the 1,000 ms degraded threshold.
-You can set a **degraded at** and **timeout** threshold whenever required. We will send you a notification whenever the responses exceed the thresholds.
+
-#### Open Telemetry
+
+
-When needed, you can export to your **OTLP endpoint** the metrics for every request we do. Access the data and set yourself an **alert channel** in Grafana, Honeycomb etc. Read more [in our guide](https://www.openstatus.dev/docs/guides/how-to-export-metrics-to-otlp-endpoint/).
+Every check records DNS, connect, TLS, time to first byte and transfer separately. A slow origin and a slow certificate handshake look the same in a total; they do not look the same here.
-### Notification channels
+- [Export every request to your OTLP endpoint](/docs/guides/how-to-export-metrics-to-otlp-endpoint)
-Set different notification channels and **get notified** whenever a response fails your assertions or exceeds your thresholds.
+
+
-We support:
+## Monitoring as code
-- Socials: Slack, Discord, Telegram Bot, Google Chat, Microsoft Teams
-- Direct: Email, WhatsApp
-- Incident Management: OpsGenie, PagerDuty, Grafana OnCall
-- Custom: Webhook, Ntfy
+
+
-### Status Pages
+Declare monitors in HCL with the Terraform provider, or from the CLI in CI. Monitor changes get code-reviewed in the PR that ships the service they watch.
-Automatically update your status page based on your uptime monitoring results. Push reports and communicate with your users during incidents. You can choose to **share response details** on your status page. Read more [about status pages](/status-page).
+Already have monitors in the dashboard? Run `openstatus terraform generate` to bootstrap an HCL file from your workspace.
-### Monitoring as Code
+- [Terraform provider](/tooling/terraform)
+- [CLI](/tooling/cli)
-Declare your monitors in HCL with the [openstatus Terraform provider](https://registry.terraform.io/providers/openstatusHQ/openstatus/latest) and roll them out in the same plan-and-apply lifecycle as the rest of your infrastructure. Monitor changes get code-reviewed in the PR that ships the service they watch.
+
+
```terraform
-resource "openstatus_http_monitor" "api" {
- name = "API Health Check"
- url = "https://api.example.com/health"
+resource "openstatus_http_monitor" "checkout" {
+ name = "Checkout API"
+ url = "https://api.piedpiper.dev/v1/checkout"
+ method = "POST"
periodicity = "1m"
- active = true
- regions = ["fly-iad", "fly-ams", "fly-syd"]
-
- headers {
- key = "Authorization"
- value = "Bearer ${var.api_token}"
- }
+ regions = ["fly-lhr", "fly-ams", "koyeb-fra", "fly-iad"]
status_code_assertions {
target = 200
comparator = "eq"
}
-}
-```
-```bash
-terraform init
-terraform plan
-terraform apply
+ degraded_at = 1000
+ timeout = 5000
+}
```
-Already have monitors in the dashboard? Run `openstatus terraform generate` to bootstrap an HCL file from your existing workspace. Read the [Terraform reference](/docs/reference/terraform) for every resource and argument.
+
+
-### Open Source & Self-Hostable
+## Private locations
-openstatus is open source under AGPL-3.0. Run the hosted version, or [self-host the full stack](/docs/guides/self-hosting-openstatus) on your own infrastructure with Docker. If you are coming from Uptime Kuma, read [how openstatus compares as a hosted alternative](/guides/hosted-uptime-kuma-alternative).
+
+
-### Private Locations
+
-Sometimes the services you want to monitor are not accessible from the outside. Simply **deploy our probes** to your favorite **cloud provider**, your own **server** or on your own **virtual private cloud** (VPC).
+
-The docker image is only **8.5MB** small and should easily fit on your infrastructure whether ARM64 or AMD64.
+The docker run command that starts a probe from ghcr.io/openstatushq/private-location, an 8.5 MB image for arm64 and amd64, and two probes online: vpc-eu-west at 10.0.4.12 and office-berlin at 192.168.1.40.
-```bash
-docker pull ghcr.io/openstatushq/private-location:latest
-```
+
-```bash
-docker run -d \
- --name openstatus-private-location \
- --restart=always \
- -e OPENSTATUS_KEY=$OPENSTATUS_KEY \
- ghcr.io/openstatushq/private-location:latest
-```
+
+
-You can read more here:
+Some services are not reachable from the internet. Run the probe in your VPC or office network with one container; results land in the same dashboard and status page. The probe dials out, so no inbound firewall rule is needed.
-- [Cloudflare Containers guide](https://www.openstatus.dev/docs/guides/how-to-deploy-probes-cloudflare-containers/)
+- [Private location docs](/docs/guides/how-to-create-private-location)
+- [Cloudflare Containers guide](/docs/guides/how-to-deploy-probes-cloudflare-containers)
- [Raspberry Pi deployment](/blog/deploy-private-locations-raspberry-pi)
-- [Private location docs](https://www.openstatus.dev/docs/guides/how-to-create-private-location/)
-
-
+
+
-**Status Page Monitors**
+## Every request, kept
-
+Response logs with the timing breakdown, headers and body per region, for as long as your plan retains data.
-
-
+- [Response logs and retention](/docs/concept/response-logs-and-retention)
+
+
+
+
+
+The response-logs table for the check, one row per region with status, latency and a DNS, connect, TLS, TTFB and transfer bar. Headers and body are kept for every failed check and every request can be exported to OTLP.
+
+
+
+## Get started
+
+
+ Create your monitor
+
+
+Free to start, with a 14-day Starter trial and no card needed. Paid plans from $30/mo.
## Frequently asked questions
-
+
Uptime monitoring is the practice of checking, on a fixed schedule and from outside your own network, whether a service is reachable and responding correctly. Checks typically run every 30 seconds to 10 minutes from probe locations around the world. Because the checks originate externally, they catch failures your internal dashboards cannot see — DNS problems, expired certificates, and regional outages.
-
+
Match the frequency to the cost of the outage. Revenue-critical endpoints justify 30-second checks; internal tooling is usually fine at 5 or 10 minutes. Higher frequency shortens the time between a failure starting and you hearing about it, but consumes more of your check quota. openstatus supports 30s, 1m, 5m and 10m intervals depending on your plan.
-
+
Uptime is what your monitor measures: the proportion of checks that succeeded. Availability is what your users experienced, which includes degraded performance your checks may have passed. A service returning HTTP 200 in eight seconds is up but arguably not available. This is why thresholds matter alongside assertions — they let you count slow responses as degraded rather than healthy.
-
+
Yes. Deploy a private location probe inside your network as an 8.5MB Docker container and it appears as another monitoring region in your dashboard. The probe reaches out to openstatus, so no inbound firewall rule is needed. You can run as many private locations as you like across different VPCs or networks.
-
+
Create an HTTP monitor pointing at the endpoint, choose the method, and add any headers your API needs for authentication. For GraphQL, send a POST with the query in the body. Then add assertions on the status code, response headers, or body content so the monitor verifies the response is correct rather than merely present, and set a threshold so slow responses register as degraded.
-
+
The free Hobby plan includes one monitor at a 10-minute interval with no credit card. Paid plans start at $30/month for Starter (20 monitors, 1-minute checks, 6 regions per monitor), $100/month for Pro (50 monitors, 30-second checks, all 28 regions), and $500/month for Scale. Annual billing gives you two months free.
-
+
Start with 3-5 regions covering your main user geographies. More regions provide better global coverage but use more check quota. For critical services, monitor from all major regions (North America, Europe, Asia) to catch regional issues quickly.
-
+
If your service runs on AWS and your monitoring also runs on AWS, you won't detect AWS-wide outages or network issues affecting AWS connectivity. Using Fly.io, Koyeb, and Railway ensures monitoring independence from your infrastructure provider.
-
+
Frequency determines how often we check your service (e.g., every 30 seconds, 1 minute, 5 minutes, 10 minutes). Higher frequency (30s) catches issues faster but uses more checks. Lower frequency (10m) is sufficient for non-critical services and conserves quota.
-
+
Yes, you can configure monitors to automatically update your status page based on monitoring results. When assertions fail or thresholds are exceeded, the status page can reflect degraded or down status without manual intervention.
-
+
Assertions validate response correctness (status code, headers, body content) while thresholds define performance boundaries (degraded latency, timeout). Both can trigger alerts - assertions catch functional failures, thresholds catch performance degradation.
-
+
You can monitor any HTTP/HTTPS endpoint including REST APIs, GraphQL APIs, webhooks, and third-party service endpoints. openstatus supports all HTTP methods (GET, POST, PUT, DELETE, etc.) and custom headers for authentication.
-
+
Yes. The openstatus Terraform provider manages HTTP, TCP, DNS and ICMP monitors — with headers, assertions and thresholds — plus notification channels, status pages and private locations. Monitors live in HCL next to the rest of your infrastructure and go through the same plan-and-apply lifecycle. Run `openstatus terraform generate` to bootstrap an HCL file from an existing workspace.
-
+
Yes, you can deploy as many private location probes as needed across different networks, VPCs, or regions. Each gets its own API key and appears as a separate monitoring region in your dashboard. The Docker image is only 8.5MB and supports ARM64 and AMD64.
-
----
-
-
-
-
-
-Free to start. Paid plans from $30/mo.
-
----
-
-Check your website's latency
-
-Global Speed Checker
-
----
diff --git a/apps/web/src/content/pages/unrelated/kitchen-sink.mdx b/apps/web/src/content/pages/unrelated/kitchen-sink.mdx
new file mode 100644
index 00000000..263877da
--- /dev/null
+++ b/apps/web/src/content/pages/unrelated/kitchen-sink.mdx
@@ -0,0 +1,258 @@
+---
+title: "Kitchen sink"
+publishedAt: "2026-09-25"
+author: "openstatus"
+description: "Every content-page component rendered once, so drift is visible. Not indexed."
+category: "internal"
+seo:
+ noindex: true
+---
+
+Every component the home and product pages are built from, in the order they appear on a page. Compare against the page you are editing; if a cell here looks different from the same cell there, something forked.
+
+## Actions
+
+
+ Get started free
+ Try the speed checker
+
+
+## Demo
+
+Every `type` in `mdx-components/demo/index.tsx`, one per grid cell.
+
+
+
+A demo renders nothing in the markdown representation. The `SrOnly` block next to it is its text alternative: visually hidden here, read by screen readers, plain copy in the markdown.
+
+
+
+
+
+
+**status-page**
+
+
+
+
+
+
+
+
+
+**monitor**
+
+
+
+
+
+
+**alert**
+
+
+
+
+
+
+**slack-agent**
+
+
+
+
+
+
+**notify**
+
+
+
+
+
+
+**access**
+
+
+
+
+
+
+**status-report**
+
+
+
+
+
+
+**audit**
+
+
+
+
+
+
+**components**
+
+
+
+
+
+
+**subscriptions**
+
+
+
+
+
+
+**translations**
+
+
+
+
+
+
+**themes**
+
+
+
+
+
+
+**import**
+
+
+
+
+
+
+**maintenance**
+
+
+
+
+
+
+**terminal**
+
+
+
+
+
+
+**regions**
+
+
+
+
+
+
+**assertions**
+
+
+
+
+
+
+**private-location**
+
+
+
+
+
+
+**timing**
+
+
+
+
+
+
+**logs**
+
+
+
+
+
+
+## Section pattern
+
+
+
+
+Two sentences. The first says what happens, the second what it saves you.
+
+- [A short list of internal links](/status-page)
+- [Two to four of them](/uptime-monitoring)
+
+
+
+
+
+
+
+
+What the demo shows, for screen readers and the `.md` representation of the page. Every section with a demo has one.
+
+
+
+
+
+
+## LogoCloud
+
+
+
+## Quote
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Cards
+
+
+
+
+**CLI**
+
+manage from your terminal
+
+
+
+
+**API**
+
+typed endpoints, OpenAPI spec
+
+
+
+
+**MCP server**
+
+Claude, ChatGPT or Cursor run it
+
+
+
+
+**Terraform**
+
+version monitors, status pages, notifications as HCL
+
+
+
+
+## Details
+
+
+
+An answer.
+
+
diff --git a/apps/web/src/content/pages/unrelated/pricing.mdx b/apps/web/src/content/pages/unrelated/pricing.mdx
index 3a626fb3..7dc4b15d 100644
--- a/apps/web/src/content/pages/unrelated/pricing.mdx
+++ b/apps/web/src/content/pages/unrelated/pricing.mdx
@@ -1,9 +1,13 @@
---
title: "Pricing"
publishedAt: "2025-11-10"
+updatedAt: "2026-09-27"
author: "Maximilian Kaske"
description: "Start free with uptime monitoring and a status page. Upgrade to Starter ($30/mo), Pro ($100/mo), or Scale ($500/mo) for more monitors, team collaboration, and advanced features. No credit card required."
category: "company"
+seo:
+ title: "Pricing — Free Status Page and Uptime Monitoring Plans"
+ description: "Start free with uptime monitoring and a status page. Starter $30/mo, Pro $100/mo, Scale $500/mo. Flat pricing, no per-seat fees, no credit card required."
faq:
- question: "What billing options are available?"
answer: "All paid plans are available with monthly or annual billing. Choose annual billing to get 2 months free — Starter at $300/year ($25/mo), Pro at $1,000/year (~$83/mo), and Scale at $5,000/year (~$416/mo)."
@@ -11,6 +15,8 @@ faq:
answer: "Yes. You can switch between monthly and annual billing at any time from your dashboard."
- question: "Do you offer a free plan?"
answer: "Yes. Our free Hobby plan includes one monitor, one status page with three components, and a minimum check interval of 10 minutes. No credit card required."
+ - question: "Is there a free trial?"
+ answer: "Yes. New workspaces start with a 14-day trial of the Starter plan, no credit card required. If you have not added a card by then, the workspace returns to the free Hobby plan."
- question: "What are add-ons and how are they billed?"
answer: "Add-ons are workspace-level settings like White Label, Custom Theme, Email Authentication, IP Restriction, extra status pages, and extra monitors. Once enabled, they apply to your entire workspace. Add-ons are billed monthly regardless of your plan's billing cycle."
- question: "Can I upgrade or downgrade my plan?"
@@ -33,6 +39,8 @@ faq:
+New workspaces start with a 14-day Starter trial, no card needed.
+
| Features comparison | $30/month - Starter | $100/month - Pro | $500/month - Scale |
| --- | --- | --- | --- |
| [Status Pages](/status-page) | | | |
@@ -88,6 +96,12 @@ Yes. Our free **Hobby** plan includes **one monitor**, **one status page with th
+
+
+Yes. New workspaces start with a **14-day trial of the Starter plan**, no credit card required. If you have not added a card by then, the workspace returns to the free Hobby plan.
+
+
+
Yes. If you are not happy with openstatus, reach out at [ping@openstatus.dev](mailto:ping@openstatus.dev) within **30 days** of your payment and we will issue a full refund — no questions asked.
diff --git a/apps/web/src/content/pages/use-case/startups.mdx b/apps/web/src/content/pages/use-case/startups.mdx
index 227f81ff..00ba8121 100644
--- a/apps/web/src/content/pages/use-case/startups.mdx
+++ b/apps/web/src/content/pages/use-case/startups.mdx
@@ -1,9 +1,13 @@
---
title: "Status Pages for Startups"
publishedAt: "2026-04-12"
+updatedAt: "2026-09-27"
author: "openstatus"
description: "Early-stage startups need a status page before they think they do — for enterprise sales, SOC 2 audits, and SLA agreements. Openstatus gives you a branded, audit-ready status page in minutes with monitoring included."
category: "Startups"
+seo:
+ title: "Status Pages for Startups — SOC 2 and Enterprise Ready"
+ description: "Startups need a status page before they think they do: enterprise sales, SOC 2 audits, SLAs. A branded, audit-ready page in minutes, monitoring included."
faq:
- question: "Do I really need a status page as an early-stage startup?"
answer: "If you're selling to other businesses, yes. Enterprise prospects check for a status page during due diligence. SOC 2 auditors expect documented incident communication (CC2.3). And your first SLA agreement will likely require one. The question isn't whether you need it — it's whether you have one when someone asks."
@@ -12,7 +16,7 @@ faq:
- question: "What does the free plan include?"
answer: "The free plan gives you 1 status page, 1 monitor, and incident reporting — enough to evaluate the product and see how it looks. When you need a custom domain, subscriber notifications, or more components, the Starter plan is $30/mo."
- question: "How is openstatus different from Atlassian Statuspage?"
- answer: "Atlassian Statuspage starts at $79/mo, charges per subscriber, requires a separate monitoring tool, and takes time to configure. Openstatus is $30/mo flat with monitoring included, no per-subscriber fees, and a status page that looks better out of the box. It's built for startups, not enterprises with dedicated SRE teams."
+ answer: "Atlassian Statuspage starts at $79/mo, charges per subscriber, requires a separate monitoring tool, and takes time to configure. Openstatus is $30/mo flat with monitoring included, and a status page that looks better out of the box. It's built for startups, not enterprises with dedicated SRE teams."
- question: "Is openstatus reliable enough to depend on?"
answer: "We're bootstrapped and profitable — no runway pressure, no risk of shutting down. Cal.com, Documenso, Midday, and other growing teams trust us. The codebase is open-source (AGPL-3.0), so you can inspect every line."
- question: "Can I use openstatus for SOC 2 compliance?"
@@ -53,7 +57,7 @@ Built-in uptime monitoring from multiple regions comes with every plan. You don'
### Flat pricing
-$30/mo flat on Starter. No per-subscriber fees. No per-seat pricing. Your team grows from 3 to 10 people and your audience grows from 50 to 1,000 subscribers — your bill stays the same.
+$30/mo flat on Starter. No per-seat pricing. Your team grows from 3 to 10 people and your audience grows from 50 to 1,000 subscribers — your bill stays the same.
### Open-source
@@ -91,7 +95,7 @@ The free plan gives you 1 status page, 1 monitor, and incident reporting — eno
-Atlassian Statuspage starts at $79/mo, charges per subscriber, requires a separate monitoring tool, and takes time to configure. Openstatus is $30/mo flat with monitoring included, no per-subscriber fees, and a status page that looks better out of the box. It's built for startups, not enterprises with dedicated SRE teams.
+Atlassian Statuspage starts at $79/mo, charges per subscriber, requires a separate monitoring tool, and takes time to configure. Openstatus is $30/mo flat with monitoring included, and a status page that looks better out of the box. It's built for startups, not enterprises with dedicated SRE teams.
diff --git a/apps/web/src/content/theme-toggle.tsx b/apps/web/src/content/theme-toggle.tsx
index c7677bb5..0360e55e 100644
--- a/apps/web/src/content/theme-toggle.tsx
+++ b/apps/web/src/content/theme-toggle.tsx
@@ -43,7 +43,7 @@ export function ThemeToggle({
return (
*]:bg-background [&>*]:hover:bg-muted [&>*]:data-[active=true]:bg-muted flex items-center gap-px [&>*]:flex [&>*]:flex-1 [&>*]:items-center [&>*]:justify-center [&>*]:p-4",
+ "bg-border [&>*]:bg-background [&>*]:hover:bg-muted [&>*]:data-[active=true]:bg-muted flex items-center gap-px [&>*]:flex [&>*]:min-w-0 [&>*]:flex-1 [&>*]:items-center [&>*]:justify-center [&>*]:p-4",
className,
)}
{...props}
@@ -53,21 +53,21 @@ export function ThemeToggle({
data-active={theme === "light"}
onClick={() => setTheme("light")}
>
- [light]
+ [light]
);
diff --git a/apps/web/src/content/utils/index.ts b/apps/web/src/content/utils/index.ts
index e9396036..76378423 100644
--- a/apps/web/src/content/utils/index.ts
+++ b/apps/web/src/content/utils/index.ts
@@ -6,6 +6,11 @@ import { type MDXData, getMDXDataFromDir, getMDXDataFromFile } from "./read";
export * from "./schema";
export type { MDXData } from "./read";
+/** Same rule for the sitemap, llms.txt and search: a noindex page is not advertised anywhere. */
+export function isIndexable(page: MDXData) {
+ return page.slug !== "not-found" && !page.metadata.seo?.noindex;
+}
+
export function getBlogPosts(): MDXData[] {
return getMDXDataFromDir(
path.join(process.cwd(), "src", "content", "pages", "blog"),
diff --git a/apps/web/src/content/utils/search-index.ts b/apps/web/src/content/utils/search-index.ts
index 1f3fe251..57ffcd8e 100644
--- a/apps/web/src/content/utils/search-index.ts
+++ b/apps/web/src/content/utils/search-index.ts
@@ -1,5 +1,11 @@
import "server-only";
-import { type MDXData, PAGE_TYPES, getHomePage, getPages } from ".";
+import {
+ type MDXData,
+ PAGE_TYPES,
+ getHomePage,
+ getPages,
+ isIndexable,
+} from ".";
import { slugify } from "../mdx";
import type { Corpus, SearchResult } from "../search-meta";
import {
@@ -11,6 +17,7 @@ import {
normalizeForMatch,
sanitizeContent,
scoreDoc,
+ stripSrOnly,
} from "./search-match";
type Heading = { slug: string; index: number };
@@ -43,7 +50,7 @@ function homeDoc(): MDXData {
}
function indexDoc(doc: MDXData, type: Corpus): IndexedDoc {
- const raw = doc.content;
+ const raw = stripSrOnly(doc.content);
const faqText = (doc.metadata.faq ?? [])
.map((f) => `${f.question} ${f.answer}`)
.join(" ");
@@ -74,7 +81,7 @@ function indexDoc(doc: MDXData, type: Corpus): IndexedDoc {
function buildCorpus(type: Corpus): IndexedDoc[] {
const pages =
type === "product" ? [homeDoc(), ...getPages("product")] : getPages(type);
- return pages.map((doc) => indexDoc(doc, type));
+ return pages.filter(isIndexable).map((doc) => indexDoc(doc, type));
}
function getCorpus(type: Corpus): IndexedDoc[] {
diff --git a/apps/web/src/content/utils/search-match.test.ts b/apps/web/src/content/utils/search-match.test.ts
index 827ca8e9..1642ae88 100644
--- a/apps/web/src/content/utils/search-match.test.ts
+++ b/apps/web/src/content/utils/search-match.test.ts
@@ -14,6 +14,7 @@ import {
normalizeForMatch,
sanitizeContent,
scoreDoc,
+ stripSrOnly,
withinEditDistance1,
} from "./search-match";
@@ -24,6 +25,23 @@ function marks(query: string, text: string): string[] {
return re ? (text.match(re) ?? []) : [];
}
+describe("stripSrOnly", () => {
+ test("removes every block and keeps the copy around it", () => {
+ const input =
+ "Kept.\n\n\n\nHidden **one**.\n\n\n\nAlso kept.\n\n\nHidden two.\n";
+ const out = stripSrOnly(input);
+ expect(out).toContain("Kept.");
+ expect(out).toContain("Also kept.");
+ expect(out).not.toContain("Hidden");
+ expect(sanitizeContent(out)).not.toContain("Hidden");
+ });
+
+ test("strips a tag that carries attributes", () => {
+ const out = stripSrOnly('Kept. Hidden.');
+ expect(out).toBe("Kept. ");
+ });
+});
+
describe("makeMatcher", () => {
test("prefix match at a word start", () => {
expect(makeMatcher("monitor").test("synthetic monitoring")).toBe(true);
diff --git a/apps/web/src/content/utils/search-match.ts b/apps/web/src/content/utils/search-match.ts
index f4747b8d..9cfe2098 100644
--- a/apps/web/src/content/utils/search-match.ts
+++ b/apps/web/src/content/utils/search-match.ts
@@ -330,6 +330,11 @@ export function findCorrection(term: string, vocab: string[]): string | null {
return best;
}
+// The text is in the DOM but invisible, so a hit on it would highlight nothing.
+export function stripSrOnly(input: string) {
+ return input.replace(/]*>[\s\S]*?<\/SrOnly>/g, "");
+}
+
export function sanitizeContent(input: string) {
return stripTags(input)
.replace(/^#{1,6}\s+/gm, "") // strip markdown heading symbols, keep text
diff --git a/apps/web/src/data/customers.ts b/apps/web/src/data/customers.ts
new file mode 100644
index 00000000..9f5ca5f7
--- /dev/null
+++ b/apps/web/src/data/customers.ts
@@ -0,0 +1,100 @@
+export type Customer = {
+ name: string;
+ /** Public status page, or the customer story when there is one. */
+ href: string;
+ /** Under `public/`; a `.dark` sibling is picked up automatically. */
+ logo?: string;
+ /** Rendered height in px; default 24. Bump for icon-only marks. */
+ logoHeight?: number;
+ story?: string;
+ quote?: { text: string; name: string; role: string };
+};
+
+// Order is the order the logo cloud renders in.
+export const customers: Customer[] = [
+ {
+ name: "Cal.com",
+ href: "https://status.cal.com",
+ logo: "/assets/customers/calcom/logo.svg",
+ },
+ {
+ name: "Twenty",
+ href: "https://twenty-status.com",
+ logo: "/assets/customers/twenty/logo.svg",
+ logoHeight: 32,
+ story: "/customers/twenty",
+ quote: {
+ text: "Open-source CRM needs an open-source status page. Openstatus took us minutes to set up — and it covers everything our customers actually depend on.",
+ name: "Félix Malfait",
+ role: "Co-founder @twentycrm",
+ },
+ },
+ {
+ name: "Documenso",
+ href: "https://status.documenso.com",
+ logo: "/assets/customers/documenso/logo.svg",
+ },
+ {
+ name: "Traefik",
+ href: "https://status.traefik.io",
+ logo: "/assets/customers/traefik/logo.png",
+ story: "/customers/traefik",
+ quote: {
+ text: "We picked openstatus because the workflow matched ours. We stayed because it keeps matching.",
+ name: "Michel Loiseleur",
+ role: "Head of Platforms @traefiklabs",
+ },
+ },
+ {
+ name: "Passbolt",
+ href: "https://passboltuptime.com",
+ logo: "/assets/customers/passbolt/logo.svg",
+ },
+ {
+ name: "Hanko",
+ href: "https://status.hanko.io",
+ logo: "/assets/customers/hanko/logo.svg",
+ logoHeight: 32,
+ },
+ {
+ name: "WhiteBIT",
+ href: "https://status.whitebit.com",
+ logo: "/assets/customers/whitebit/logo.svg",
+ },
+ {
+ name: "Superwall",
+ href: "https://status.superwall.com",
+ logo: "/assets/customers/superwall/logo.svg",
+ },
+ {
+ name: "OpenPanel",
+ href: "https://status.openpanel.dev",
+ logo: "/assets/customers/openpanel/logo.svg",
+ logoHeight: 20,
+ },
+ {
+ name: "Probo",
+ href: "https://probostatus.com",
+ logo: "/assets/customers/probo/logo.svg",
+ },
+ {
+ name: "Roundtable",
+ // Their status page is password-protected, so link the company site.
+ href: "https://roundtable.eu",
+ logo: "/assets/customers/roundtable/logo.svg",
+ logoHeight: 18,
+ },
+ {
+ name: "Smplrspace",
+ href: "https://status.smplrspace.com",
+ logo: "/assets/customers/smplrspace/logo.svg",
+ },
+];
+
+export function getCustomer(name: string) {
+ const customer = customers.find(
+ (c) => c.name.toLowerCase() === name.toLowerCase(),
+ );
+ if (!customer) throw new Error(`Unknown customer "${name}"`);
+ return customer;
+}
diff --git a/apps/web/src/data/demo-data.ts b/apps/web/src/data/demo-data.ts
new file mode 100644
index 00000000..8f14af3d
--- /dev/null
+++ b/apps/web/src/data/demo-data.ts
@@ -0,0 +1,486 @@
+import type {
+ Maintenance,
+ StatusBarData,
+ StatusReport,
+ StatusReportUpdateType,
+ StatusType,
+} from "@openstatus/ui/components/blocks/status.types";
+
+const company = {
+ name: "Pied Piper",
+ slug: "pied-piper",
+ domain: "status.piedpiper.dev",
+ icon: "/assets/landing/pied-piper.png",
+ internalDomain: "internal.piedpiper.dev",
+ internalPassword: "pied-piper-internal",
+ ipAllowlist: "203.0.113.0/24",
+ slackChannel: "#incidents",
+ // One enterprise customer, for the shared Slack Connect channel.
+ customer: "Hooli",
+ // The engineer who declares the incident from Slack.
+ oncall: {
+ name: "Gilfoyle",
+ initials: "G",
+ email: "gilfoyle@piedpiper.dev",
+ },
+} as const;
+
+const components = [
+ {
+ name: "Checkout API",
+ group: "Payments",
+ status: "degraded",
+ uptime: "99.94%",
+ degradedDays: [21],
+ incident: true,
+ },
+ {
+ name: "Webhooks",
+ group: "Payments",
+ status: "success",
+ uptime: "99.99%",
+ degradedDays: [9],
+ },
+ {
+ name: "Stripe",
+ group: "Payments",
+ status: "success",
+ uptime: "—",
+ external: true,
+ degradedDays: [],
+ },
+ {
+ name: "Dashboard",
+ group: "Platform",
+ status: "success",
+ uptime: "100%",
+ degradedDays: [],
+ },
+ {
+ name: "Docs",
+ group: "Platform",
+ status: "success",
+ uptime: "100%",
+ degradedDays: [],
+ },
+] as const satisfies {
+ name: string;
+ group: string;
+ status: Exclude;
+ uptime: string;
+ external?: boolean;
+ /** Degraded bars, as indices from the oldest day; the incident day is added by `getStatusBarData`. */
+ degradedDays: number[];
+ /** Carries the incident event on `getIncidentDay`. */
+ incident?: boolean;
+}[];
+
+export type DemoComponent = (typeof components)[number];
+
+const subscribers = { email: 1_337, slackConnect: 3 } as const;
+const actor = `${company.oncall.email} · slack`;
+
+/**
+ * One fictional company, one incident. Every `` on every content page
+ * renders a moment of this story so the demos read as a single narrative.
+ */
+export const demo = {
+ company,
+ monitor: {
+ name: "Checkout API",
+ method: "POST",
+ url: "https://api.piedpiper.dev/v1/checkout",
+ periodicity: "1m",
+ degradedAfter: 1_000,
+ timeout: 5_000,
+ headers: [
+ { key: "authorization", value: "Bearer ••••••••" },
+ { key: "content-type", value: "application/json" },
+ ],
+ assertions: [
+ {
+ target: "status code",
+ comparator: "equals",
+ value: "200",
+ pass: false,
+ got: "503",
+ },
+ {
+ target: "header content-type",
+ comparator: "contains",
+ value: "json",
+ pass: true,
+ },
+ {
+ target: "body",
+ comparator: "contains",
+ value: '"session_id"',
+ pass: false,
+ },
+ ],
+ },
+ incident: {
+ title: "Elevated errors on Checkout API",
+ affected: ["Checkout API"],
+ updates: [
+ {
+ status: "investigating",
+ time: "09:44",
+ message:
+ "We are investigating elevated error rates on the Checkout API from European regions. Payments outside Europe are not affected.",
+ },
+ {
+ status: "identified",
+ time: "09:52",
+ message:
+ "A configuration change in our European edge caused the Checkout API to return 503 for requests routed through Europe. A rollback is in progress.",
+ },
+ {
+ status: "monitoring",
+ time: "10:14",
+ message:
+ "The rollback has completed in eu-west-1 and eu-west-2. Error rates are back to baseline; we are monitoring for the next hour.",
+ },
+ {
+ status: "resolved",
+ time: "10:36",
+ message:
+ "Error rates have stayed at baseline for the last hour. This incident is resolved.",
+ },
+ ] satisfies {
+ status: StatusReportUpdateType;
+ time: string;
+ message: string;
+ }[],
+ },
+ maintenance: {
+ title: "Database upgrade",
+ affected: ["Checkout API", "Webhooks"],
+ message:
+ "We are upgrading the primary database. Expect up to five minutes of elevated latency on checkout and delayed webhook delivery.",
+ hoursFromNow: 72,
+ durationHours: 2,
+ },
+ components,
+ // The regions the Checkout API monitor runs from. An alert needs at least half
+ // of them to fail the same check; the four European ones do, the failing ones first.
+ regions: [
+ { code: "lhr", city: "London", cloud: "Fly", ms: 4_388, status: 503 },
+ { code: "ams", city: "Amsterdam", cloud: "Fly", ms: 4_102, status: 503 },
+ { code: "cdg", city: "Paris", cloud: "Fly", ms: 4_051, status: 503 },
+ {
+ code: "koyeb_fra",
+ city: "Frankfurt",
+ cloud: "Koyeb",
+ ms: 3_970,
+ status: 503,
+ },
+ { code: "iad", city: "Virginia", cloud: "Fly", ms: 231, status: 200 },
+ { code: "sjc", city: "San Jose", cloud: "Fly", ms: 244, status: 200 },
+ ],
+ // One check, phase by phase. The handshake is fine; the edge holds the
+ // request before answering 503, so the wait shows up as TTFB.
+ timing: {
+ region: "lhr",
+ phases: [
+ { phase: "DNS", ms: 12 },
+ { phase: "Connect", ms: 38 },
+ { phase: "TLS", ms: 61 },
+ { phase: "TTFB", ms: 4_265 },
+ { phase: "Transfer", ms: 12 },
+ ],
+ },
+ channels: [
+ { name: "Slack", state: "sent" },
+ { name: "PagerDuty", state: "paged" },
+ { name: "Email", state: "sent" },
+ { name: "Webhook", state: "200" },
+ { name: "Discord", state: "off" },
+ { name: "Opsgenie", state: "off" },
+ { name: "SMS", state: "off" },
+ { name: "Teams", state: "off" },
+ ],
+ subscribers,
+ audit: [
+ {
+ time: "10:36:00",
+ action: "status_report.update",
+ detail: "→ resolved",
+ actor,
+ },
+ {
+ time: "10:14:03",
+ action: "status_report.update",
+ detail: "→ monitoring",
+ actor,
+ },
+ {
+ time: "09:52:41",
+ action: "notification.send",
+ detail: `email ${subscribers.email.toLocaleString("en-US")} · rss · slack-connect`,
+ actor: "system",
+ },
+ {
+ time: "09:52:40",
+ action: "status_report.update",
+ detail: "→ identified",
+ actor,
+ },
+ {
+ time: "09:44:31",
+ action: "notification.send",
+ detail: `email ${subscribers.email.toLocaleString("en-US")} · rss · slack-connect`,
+ actor: "system",
+ },
+ {
+ time: "09:44:30",
+ action: "status_report.create",
+ detail: "investigating · Checkout API",
+ actor,
+ },
+ {
+ time: "09:41:12",
+ action: "monitor.alert",
+ detail: "Checkout API · lhr, ams, cdg, koyeb_fra",
+ actor: "probe",
+ },
+ ],
+ // Labels as the status page ships them (apps/status-page/messages/*.json).
+ locales: [
+ {
+ code: "en",
+ name: "English",
+ systemStatus: {
+ success: { long: "All Systems Operational", short: "Operational" },
+ degraded: { long: "Degraded Performance", short: "Degraded" },
+ },
+ },
+ {
+ code: "de",
+ name: "Deutsch",
+ systemStatus: {
+ success: {
+ long: "Alle Systeme betriebsbereit",
+ short: "Betriebsbereit",
+ },
+ degraded: { long: "Eingeschränkte Leistung", short: "Eingeschränkt" },
+ },
+ },
+ {
+ code: "fr",
+ name: "Français",
+ systemStatus: {
+ success: {
+ long: "Tous les systèmes sont opérationnels",
+ short: "Opérationnel",
+ },
+ degraded: { long: "Performances dégradées", short: "Dégradé" },
+ },
+ },
+ {
+ code: "ja",
+ name: "日本語",
+ systemStatus: {
+ success: { long: "全システム正常稼働", short: "正常稼働" },
+ degraded: { long: "性能低下", short: "性能低下" },
+ },
+ },
+ ],
+ import: {
+ provider: "Atlassian Statuspage",
+ apiKey: "••••••••••••••••7f2a",
+ counts: [
+ { label: "Components", value: components.length },
+ { label: "Groups", value: new Set(components.map((c) => c.group)).size },
+ { label: "Status Reports", value: 37 },
+ { label: "Maintenances", value: 3 },
+ { label: "Subscribers", value: subscribers.email },
+ {
+ label: "Monitors",
+ value: components.filter((c) => !("external" in c)).length,
+ },
+ ],
+ },
+ privateLocation: {
+ image: "ghcr.io/openstatushq/private-location:latest",
+ imageSize: "8.5 MB",
+ probes: [
+ { name: "vpc-eu-west", ip: "10.0.4.12", seen: "2s ago" },
+ { name: "office-berlin", ip: "192.168.1.40", seen: "5s ago" },
+ ],
+ },
+} as const;
+
+export const SSH_HOST = "ssh.openstatus.dev";
+export const feedUrl = `https://${company.domain}/feed`;
+export const sshCommand = `ssh ${company.slug}@${SSH_HOST}`;
+
+export function formatNumber(n: number) {
+ return n.toLocaleString("en-US");
+}
+
+/** `231 ms` below a second, `4.39 s` above. */
+export function formatMs(ms: number) {
+ return ms < 1_000 ? `${formatNumber(ms)} ms` : `${(ms / 1_000).toFixed(2)} s`;
+}
+
+export function capitalize(s: string) {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+/** Components backed by a monitor; external services are set by hand. */
+export function getMonitors() {
+ return demo.components.filter((c) => !("external" in c && c.external));
+}
+
+export function worstStatus(
+ list: readonly { status: DemoComponent["status"] }[],
+): DemoComponent["status"] {
+ return list.some((c) => c.status === "degraded") ? "degraded" : "success";
+}
+
+/** Components grouped in page order, each group carrying its worst status. */
+export function getGroups(list: readonly DemoComponent[] = demo.components) {
+ return [...new Set(list.map((c) => c.group))].map((name) => {
+ const items = list.filter((c) => c.group === name);
+ return { name, items, status: worstStatus(items) };
+ });
+}
+
+/** The audit row every timestamp in a demo has to trace back to. */
+export function auditRow(action: string, detail?: string) {
+ const row = demo.audit.find(
+ (r) => r.action === action && (detail === undefined || r.detail === detail),
+ );
+ if (!row) throw new Error(`No audit row for ${action} ${detail ?? ""}`);
+ return row;
+}
+
+/** `HH:MM` of an `HH:MM:SS` audit time. */
+export function hhmm(time: string) {
+ return time.slice(0, 5);
+}
+
+const DAYS = 45;
+
+/** Anchor the incident on the most recent day where its whole timeline is in the past. */
+export function getIncidentDay(now = new Date()) {
+ const day = new Date(now);
+ day.setUTCHours(0, 0, 0, 0);
+ const lastUpdate = demo.incident.updates[demo.incident.updates.length - 1];
+ if (now < atTime(day, lastUpdate.time)) day.setUTCDate(day.getUTCDate() - 1);
+ return day;
+}
+
+/** `HH:MM` or `HH:MM:SS` UTC on the given day. */
+export function atTime(day: Date, time: string) {
+ const [h, m, s = 0] = time.split(":").map(Number);
+ const date = new Date(day);
+ date.setUTCHours(h, m, s, 0);
+ return date;
+}
+
+/** Minutes from the first update to the last. */
+export function getIncidentMinutes() {
+ const { updates } = demo.incident;
+ const day = new Date(0);
+ const from = atTime(day, updates[0].time);
+ const to = atTime(day, updates[updates.length - 1].time);
+ return Math.round((to.getTime() - from.getTime()) / 60_000);
+}
+
+/** `52m`, `23h 8m`. */
+export function formatMinutes(minutes: number) {
+ const h = Math.floor(minutes / 60);
+ const m = minutes % 60;
+ if (h === 0) return `${m}m`;
+ return m === 0 ? `${h}h` : `${h}h ${m}m`;
+}
+
+/**
+ * The incident as a `StatusReport`, optionally cut off after a given update so a
+ * demo can show the page mid-incident.
+ */
+export function getIncident(
+ upTo: StatusReportUpdateType = "resolved",
+ now = new Date(),
+): StatusReport {
+ const day = getIncidentDay(now);
+ const index = demo.incident.updates.findIndex((u) => u.status === upTo);
+ return {
+ id: 1,
+ title: demo.incident.title,
+ affected: [...demo.incident.affected],
+ updates: demo.incident.updates.slice(0, index + 1).map((u) => ({
+ status: u.status,
+ message: u.message,
+ date: atTime(day, u.time),
+ })),
+ };
+}
+
+export function getMaintenance(now = new Date()): Maintenance {
+ const from = new Date(
+ now.getTime() + demo.maintenance.hoursFromNow * 3_600_000,
+ );
+ from.setUTCMinutes(0, 0, 0);
+ return {
+ id: 2,
+ title: demo.maintenance.title,
+ affected: [...demo.maintenance.affected],
+ message: demo.maintenance.message,
+ from,
+ to: new Date(from.getTime() + demo.maintenance.durationHours * 3_600_000),
+ };
+}
+
+/** 45 days of bar data ending today. A component with `incident` gets a degraded bar and the event on `getIncidentDay`. */
+export function getStatusBarData(
+ component: { degradedDays: readonly number[]; incident?: boolean },
+ now = new Date(),
+): StatusBarData[] {
+ const incident = getIncident("resolved", now);
+ const incidentDay = getIncidentDay(now);
+ const incidentMinutes = getIncidentMinutes();
+ return Array.from({ length: DAYS }, (_, i) => {
+ const day = new Date(now);
+ day.setUTCHours(0, 0, 0, 0);
+ day.setUTCDate(day.getUTCDate() - (DAYS - 1 - i));
+ const isIncidentDay =
+ component.incident === true && day.getTime() === incidentDay.getTime();
+ const degraded = component.degradedDays.includes(i) || isIncidentDay;
+ // Other degraded days are past blips with no report attached.
+ const degradedMinutes = isIncidentDay ? incidentMinutes : 18;
+ return {
+ day: day.toISOString(),
+ // The band is exaggerated so a sub-hour incident stays visible at 45-day scale.
+ bar: degraded
+ ? [
+ { status: "success", height: 75 },
+ { status: "degraded", height: 25 },
+ ]
+ : [{ status: "success", height: 100 }],
+ card: degraded
+ ? [
+ {
+ status: "success",
+ value: formatMinutes(24 * 60 - degradedMinutes),
+ },
+ { status: "degraded", value: formatMinutes(degradedMinutes) },
+ ]
+ : [{ status: "success", value: "24h" }],
+ events: isIncidentDay
+ ? [
+ {
+ id: incident.id,
+ name: incident.title,
+ type: "report",
+ from: incident.updates[0].date,
+ to: incident.updates[incident.updates.length - 1].date,
+ status: "degraded",
+ },
+ ]
+ : [],
+ };
+ });
+}
diff --git a/apps/web/src/lib/metadata/shared-metadata.ts b/apps/web/src/lib/metadata/shared-metadata.ts
index e9ce803b..8939fbc6 100644
--- a/apps/web/src/lib/metadata/shared-metadata.ts
+++ b/apps/web/src/lib/metadata/shared-metadata.ts
@@ -6,7 +6,7 @@ export const TITLE = "openstatus";
export const HOMEPAGE_TITLE =
"Free & Open Source Status Page and Uptime Monitoring";
export const DESCRIPTION =
- "Open source status page and uptime monitoring. Keep it in code, let your agents update it. Free to start, self-hostable.";
+ "Open source status page and uptime monitoring. Your monitors update it, your subscribers hear it from you first. Free to start, self-hostable.";
export const OG_DESCRIPTION = "The status page for humans and agents";
@@ -119,3 +119,28 @@ export const getPageMetadata = (page: MDXData, basePath?: string): Metadata => {
twitter,
};
};
+
+// Home sits outside `getPageMetadata`: its canonical is `/`, its title skips
+// the `%s | openstatus` template and its OG card is the static one.
+export const getHomeMetadata = (page: MDXData): Metadata => {
+ const { title, description, category, seo } = page.metadata;
+ const metaTitle = seo?.title ?? title;
+ const metaDescription = seo?.description ?? description;
+
+ return {
+ ...defaultMetadata,
+ title: { absolute: metaTitle },
+ description: metaDescription,
+ alternates: {
+ canonical: seo?.canonical ?? "/",
+ },
+ ...(seo?.noindex ? { robots: { index: false } } : {}),
+ ...getSocialMetadata({
+ title: metaTitle,
+ description: metaDescription,
+ url: BASE_URL,
+ category,
+ ogImage: seo?.ogImage ?? `${BASE_URL}/api/og`,
+ }),
+ };
+};
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css
index f6fe0249..1379a225 100644
--- a/apps/web/src/styles/globals.css
+++ b/apps/web/src/styles/globals.css
@@ -48,6 +48,11 @@
--font-mono: var(--font-commit-mono);
}
+/* Article link look for `not-prose` islands (quotes, demos) that still want a real link. */
+@utility link {
+ @apply text-foreground underline decoration-muted-foreground/50 underline-offset-2 decoration-2 hover:decoration-muted-foreground transition-colors duration-150 motion-reduce:transition-none;
+}
+
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@@ -148,8 +153,10 @@
@apply visible;
}
-.prose a {
- @apply text-foreground underline decoration-muted-foreground/50 underline-offset-2 decoration-2 hover:decoration-muted-foreground transition-colors duration-150 motion-reduce:transition-none;
+/* Every element rule below stops at `.not-prose`, so demos and other embedded
+ UI inherit nothing from the article they sit in. */
+.prose a:not(:where(.not-prose, .not-prose *)) {
+ @apply link;
}
.prose .anchor:after {
@@ -161,30 +168,30 @@
@apply visible;
}
-.prose pre {
+.prose pre:not(:where(.not-prose, .not-prose *)) {
@apply relative bg-muted overflow-x-auto border border-border py-2.5 px-3.5 text-sm rounded-none;
}
-.prose code {
+.prose code:not(:where(.not-prose, .not-prose *)) {
@apply px-1 py-0.5 bg-muted text-muted-foreground;
}
-.prose pre code {
+.prose pre code:not(:where(.not-prose, .not-prose *)) {
@apply p-0;
border: initial;
line-height: 1.5;
}
-.prose code span {
+.prose code span:not(:where(.not-prose, .not-prose *)) {
@apply font-medium;
}
-.prose img {
+.prose img:not(:where(.not-prose, .not-prose *)) {
/* Don't apply styles to next/image */
@apply m-0;
}
-.prose p {
+.prose p:not(:where(.not-prose, .not-prose *)) {
@apply my-4 leading-7;
}
@@ -200,6 +207,15 @@
@apply text-2xl text-foreground font-semibold tracking-tight mt-12 mb-4 pt-8 border-t border-border/60 relative scroll-mt-12;
}
+/* An `Eyebrow` takes over the rule and spacing above the heading it precedes. */
+.prose [data-slot="eyebrow"] {
+ @apply mt-12 mb-2 pt-8 border-t border-border/60;
+}
+
+.prose [data-slot="eyebrow"] + h2 {
+ @apply mt-0 pt-0 border-t-0;
+}
+
.prose h3 {
@apply text-xl text-foreground font-medium tracking-tight mt-8 mb-3 relative scroll-mt-8;
}
@@ -208,60 +224,60 @@
@apply text-lg text-foreground font-medium tracking-tight mt-6 mb-2 relative scroll-mt-6;
}
-.prose hr {
+.prose hr:not(:where(.not-prose, .not-prose *)) {
@apply my-6
}
-.prose strong {
+.prose strong:not(:where(.not-prose, .not-prose *)) {
@apply text-foreground font-semibold;
}
-.prose blockquote {
+.prose blockquote:not(:where(.not-prose, .not-prose *)) {
@apply border-l-2 border-foreground/50 pl-4;
}
-.prose blockquote p::before {
+.prose blockquote p:not(:where(.not-prose, .not-prose *))::before {
content: '"';
@apply text-muted-foreground;
}
-.prose blockquote p::after {
+.prose blockquote p:not(:where(.not-prose, .not-prose *))::after {
content: '"';
@apply text-muted-foreground;
}
-.prose figure {
+.prose figure:not(:where(.not-prose, .not-prose *)) {
@apply my-4;
}
-.prose figure img {
+.prose figure img:not(:where(.not-prose, .not-prose *)) {
@apply aspect-video object-cover border border-border outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10;
}
-.prose figure img.img-fit {
+.prose figure img.img-fit:not(:where(.not-prose, .not-prose *)) {
/* opt out of the forced 16:9 crop: show non-16:9 images (wide diagrams, terminal strips, portrait cards) whole */
aspect-ratio: auto;
object-fit: contain;
}
-.prose figure figcaption {
+.prose figure figcaption:not(:where(.not-prose, .not-prose *)) {
@apply text-sm text-muted-foreground not-empty:mt-1;
}
-.prose em {
+.prose em:not(:where(.not-prose, .not-prose *)) {
@apply text-foreground italic;
}
-.prose ul {
+.prose ul:not(:where(.not-prose, .not-prose *)) {
list-style-type: "– ";
@apply list-outside pl-5 marker:text-muted-foreground;
}
-.prose ol {
+.prose ol:not(:where(.not-prose, .not-prose *)) {
@apply list-outside pl-5 list-decimal marker:text-muted-foreground;
}
-.prose li {
+.prose li:not(:where(.not-prose, .not-prose *)) {
@apply leading-7 pl-1;
}
@@ -300,11 +316,11 @@
@apply mb-2;
}
-.prose form {
+.prose form:not(:where(.not-prose, .not-prose *)) {
@apply my-4;
}
-.prose form label {
+.prose form label:not(:where(.not-prose, .not-prose *)) {
@apply text-foreground font-medium;
}
@@ -313,19 +329,19 @@
@apply w-full overflow-x-auto my-4;
}
-.prose table {
+.prose table:not(:where(.not-prose, .not-prose *)) {
@apply w-full border-collapse border border-border text-foreground;
}
-.prose table th {
+.prose table th:not(:where(.not-prose, .not-prose *)) {
@apply font-medium bg-muted/50 p-4 text-left border border-border;
}
-.prose table td {
+.prose table td:not(:where(.not-prose, .not-prose *)) {
@apply font-normal p-4 border border-border;
}
-.prose table caption {
+.prose table caption:not(:where(.not-prose, .not-prose *)) {
@apply text-muted-foreground text-sm caption-bottom mt-4;
}
@@ -348,4 +364,16 @@ input[type='email'] {
.title {
text-wrap: balance;
-}
\ No newline at end of file
+}
+
+/* Same theme as the status page (apps/status-page/src/app/globals.css): no
+ radius, `.rounded-full` derived from it. Root-level so portaled content
+ (hover cards, popovers, menus mounted on body) matches the blocks that opened it. */
+:root {
+ --radius: 0rem;
+}
+@layer utilities {
+ .rounded-full {
+ border-radius: calc(var(--radius) * 99999999);
+ }
+}
diff --git a/packages/services/src/page-subscriber/slack.ts b/packages/services/src/page-subscriber/slack.ts
index 954f9ee0..5cf39b90 100644
--- a/packages/services/src/page-subscriber/slack.ts
+++ b/packages/services/src/page-subscriber/slack.ts
@@ -37,7 +37,7 @@ function channelLabel(channelId: string, channelName?: string): string {
}
/**
- * Self-signup for a Slack channel via `/openstatus add `. There is no
+ * Self-signup for a Slack channel via `/openstatus subscribe `. There is no
* authenticated workspace at the call site — both workspace and audit actor
* are resolved from the page. Auto-accepted (the slash command is consent).
*/
--
2.51.2