EmDash is an Astro-based CMS that extends
through plugins. StatusBeam ships one native plugin for it,
@statusbeam/emdash-plugin. It runs the checks, serves the public status JSON
and badges, adds a React admin for monitors and status, and registers a Status
block for Portable Text. This guide covers how to install it, how to operate it,
and which deployment shape keeps your status page up when the CMS is down. The
design rationale is in
ADR-0005.
What you install
| Package | What it is | Use it to |
|---|---|---|
@statusbeam/emdash-plugin |
A native EmDash plugin with the id statusbeam |
Run checks, store history, send notifications, serve the public JSON and badges, edit monitors and view status in the admin, and embed a Status block in content |
@statusbeam/emdash-ui |
A plain Astro component library, not a plugin | Show status on your own pages: a banner, 90-day uptime bars and per-site detail. The plugin depends on it |
The components read the same public JSON from either source:
- The plugin on the same EmDash site.
- A standalone StatusBeam deployment (see Deploy to Cloudflare).
Install the plugin
Add the package:
bun add @statusbeam/emdash-pluginYour site provides the peer dependencies: emdash (~1.3.1), astro (^7),
react (^19), @cloudflare/kumo (2.6.0) and @emdash-cms/admin
(~1.3.1). The emdash range is pinned to ~1.3.1 on purpose: the plugin
relies on an EmDash export that the EmDash docs do not cover, so re-check the
plugin after you upgrade EmDash.
Register the plugin in plugins, not in sandboxed:
import { defineConfig } from 'astro/config'
import emdash from 'emdash/astro'
import { statusbeam } from '@statusbeam/emdash-plugin'
export default defineConfig({
integrations: [
emdash({
plugins: [statusbeam()],
}),
],
})No sandbox runner and no Worker Loader binding are needed. The plugin loads the
same way on Node and on Cloudflare Workers. It ships TypeScript and .astro
source, which your site’s Vite and Astro build compiles.
The reference setup is apps/emdash-demo in the repository.
The package has four entries: . (the statusbeam() descriptor), ./plugin
(createPlugin), ./admin (the React pages) and ./astro (the Status block
renderer). The descriptor wires them up, so you only import statusbeam.
The network capability
The plugin declares one capability, network:request:unrestricted, because the
monitored hosts are your own configuration and change at runtime.
The host still protects every probe. It refuses private, loopback and
cloud-metadata addresses, resolves names to check where they point, and caps
redirects, also on every redirect hop. A monitor that points at localhost or an
internal address cannot be checked by the plugin. Use a standalone deployment
for those.
Options
statusbeam() takes one option:
statusbeam({
allowedOrigins: ['https://status.example.com', 'https://cms.example.com'],
})allowedOrigins restricts the origins a Status block may fetch. See
Restrict block sources.
Admin pages
The plugin adds three things to the EmDash admin. All of them are for administrators: a non-administrator sees a refusal and no configuration.
- Status (
/_emdash/admin/plugins/statusbeam/) lists every monitor with its status, 90-day bars and latest latency. A monitor that has not been checked yet reads “Not checked yet” and shows no uptime figure. - Monitors (
/_emdash/admin/plugins/statusbeam/monitors) edits the monitor configuration. - A dashboard widget shows the overall status and the counts of operational, degraded and major-outage monitors. The widget and the Status page both read “Waiting for the first check” until the first run completes.
The page text follows the admin language in English, Chinese, Japanese and Korean, and falls back to English. The sidebar labels “Status” and “Monitors” are English only, and so are the Status block’s slash-menu entry and field labels in the editor.
Configure monitors
Open Monitors. You add, edit and remove monitors in a form. It saves the same configuration as a standalone deployment, validated by the same schema.
- An invalid field shows its error next to that field, and nothing is saved. Duplicate slugs are one such error.
- Renaming a slug saves it as a new monitor.
- Test runs one check for a monitor, including an unsaved edit, and shows its status, latency and response code. It writes no history, snapshot or notification.
- If another administrator saved after you loaded the page, your save is refused and the newer configuration is shown.
- The form does not edit the Sentry or AI Gateway monitor blocks, but a save keeps them as they were.
The configuration is stored as YAML. The first save from the admin rewrites it from the form, so comments in the YAML are dropped.
The plugin applies a few rules on top of the shared schema:
delivery: queueis an error. The plugin has no queue delivery.tcpandsslmonitors warn. The plugin checks them over HTTP, not as a TCP or TLS probe.- A monitor that polls Sentry warns when no Sentry token is set.
- Duplicate slugs are an error.
- More than 200 monitors is an error. The plugin stores every monitor’s state in one database row (about 6 to 9 KB per monitor), and Cloudflare D1 limits a row to 2 MB.
- A Slack or webhook URL in the YAML is an error. Put it in the plugin settings instead.
Secrets
Credentials never go in the YAML, because a webhook URL embeds its credential.
They are encrypted plugin settings, edited on EmDash’s plugin settings page
(/_emdash/admin/plugins-manager/statusbeam/settings). The Monitors page shows
only whether each one is set, with a link to that page.
| Setting | Holds |
|---|---|
slackWebhookUrl |
The Slack incoming-webhook URL. Status changes post here. |
webhookUrls |
Generic webhook URLs, one per line. Each receives the status-change JSON. |
webhookSecret |
The shared token for inbound Statuspage and Sentry webhooks. |
sentryToken |
The Sentry auth token for monitors with a Sentry poll backstop. |
Delivery logs never contain a full URL.
How it runs
The plugin checks every pollable monitor every 5 minutes, from one scheduled
task named check. It probes them all at once. A Sentry monitor with no sentry
block (webhook-only) is not polled, and neither is a Sentry monitor that polls
while the sentryToken setting is empty; Sentry webhooks update the first kind.
A daily maintenance task prunes
history older than 90 days. The plugin creates or repairs these tasks when it
activates, on every run and on every save, and it touches only a task that is
missing or changed.
How often the host wakes the task depends on where the site runs:
| Host | What fires the task |
|---|---|
| Node | An in-process timer, on the minute. |
| Cloudflare (a built Worker) | The Worker’s scheduled() handler, so the site needs a Cron Trigger such as * * * * *. |
Cloudflare (astro dev) |
A dev scheduler that polls every 60 seconds, so a task can run up to about a minute late. |
The plugin re-validates the stored configuration on every run. A run whose configuration is invalid checks nothing and changes no status.
Probe behavior
- Latency is time to the response headers for the header-only checks (plain
HTTP checks), as on a standalone deployment, so a slow body does not make a
healthy monitor
degraded. Numbers from the two are comparable. The body-reading checks (Statuspage, incident.io, Sentry and AI Gateway) include the body read in their latency. - A response body is not read for plain HTTP checks, so a body over 8 MiB is graded by its status code. Monitors that must read the body (Statuspage, incident.io, Sentry and AI Gateway) are bound by the host’s 8 MiB body limit.
- A probe has a 10-second deadline. A target that has not answered by then is
graded
downwith code 0, and the other monitors finish normally. - Private addresses are refused, including behind a redirect. See The network capability.
What differs from standalone
tcpandsslmonitors run as HTTP checks.- There is no queue delivery. Notifications go out directly.
- The monitor configuration lives in plugin settings, not in
status.config.yml.
Public endpoints
The plugin serves public, unauthenticated GET routes under
/_emdash/api/plugins/statusbeam. They return the same JSON as the standalone
/api/* endpoints, with no wrapper.
| Plugin route | Standalone equivalent |
|---|---|
status |
/api/status.json |
status?slug=<slug> |
/api/status/<slug>.json |
badge, badge?slug=<slug> |
/api/badge.json, /api/badge/<slug>.json |
badge-uptime?slug=<slug>&period=<period> |
/api/badge/<slug>/uptime.json |
badge-response-time?slug=<slug> |
/api/badge/<slug>/response-time.json |
A monitor with no real result yet (one added since the last run, or a
webhook-only Sentry monitor before its first event) is served as not checked
yet: "pending": true beside a placeholder "status": "down", as standalone
serves it. The overall status ignores it, its badges read not checked in grey,
the @statusbeam/emdash-ui components label it “Not checked yet”, and its first
real result sends no status-change notification.
An unknown slug returns the same 404 body as standalone, and an invalid
period falls back to month. Successful responses carry the standalone cache
policy, public, s-maxage=60, stale-while-revalidate=600.
Two things differ from a standalone deployment:
- No cross-origin header. EmDash raw routes cannot set
Access-Control-Allow-Origin, so a browser script on another origin cannot read these endpoints. Fetch them on the server. The@statusbeam/emdash-uicomponents already do. - Time-based caching only. Raw routes cannot set
Cache-Tag, so a status change does not purge the edge cache. A change shows up when the cache entry expires.
Inbound webhooks
The plugin accepts Statuspage and Sentry webhooks:
POST /_emdash/api/plugins/statusbeam/webhooks/statuspage?slug=<slug>&token=<secret>
POST /_emdash/api/plugins/statusbeam/webhooks/sentry?slug=<slug>&token=<secret>The routes fail closed. While webhookSecret is unset, every request is
rejected. A request with a wrong token is rejected before its body is parsed or
anything is stored. A valid token with an unknown slug, or a provider that does not match the
monitor’s check kind, returns 404 and changes nothing. A token in a query string
can appear in server logs, so use a long random value that you can rotate.
Choose a deployment shape
| Shape | Checks run in | The page reads from | Survives a CMS or host outage |
|---|---|---|---|
| Everything in EmDash | The plugin | The plugin’s routes | No. Checks and page stop with the CMS. |
| Standalone engine, EmDash page | A standalone StatusBeam deployment | The standalone /api/*, through @statusbeam/emdash-ui |
Partly. Checks and data stay up; the EmDash-rendered page goes down with its host. |
| Standalone engine and page | A standalone StatusBeam deployment | The standalone deployment’s own page | Yes. Nothing depends on the CMS. |
The standalone deployment keeps checks and the status page independent of a CMS
or host outage. Pointing the Status block or @statusbeam/emdash-ui at it keeps
the checks and their data independent, but the EmDash site only displays status:
if that site is down, its pages are down too. Serve the page from the standalone
deployment’s own site when the page must stay reachable during a CMS outage.
Pick the shape like this:
- Use the standalone deployment when the status page is how customers learn about an outage, or when you need TCP or SSL probes or internal hosts.
- Use the plugin when a status view inside your own EmDash site is enough and you accept that an outage of that site takes it down. It trades the independence guarantee for managing monitors in the EmDash admin.
Embed a Status block
The plugin registers a “Status” block in the Portable Text editor. The editor chooses a view (banner or site detail), a site slug, a status source and a Source type:
- EmDash plugin: the block reads the plugin’s own routes on the same site.
- Standalone: the block reads that deployment’s
/api/*.
A post that already holds a statusbeam-status block keeps rendering after you
move to the native plugin, because the content stores only the block type and
its fields.
Restrict block sources (recommended)
The block renderer fetches the status source URL on the server. It always
refuses credentials, a query string and a fragment, and it does not follow
redirects: a source that answers with one renders as unavailable, so enter its
final URL. Without configuration, a production build also refuses localhost
and loopback, private, link-local and unique-local IP literals; the
development server (astro dev) accepts them so a local fixture works. It cannot
see where a hostname resolves, so an editor can still enter a name that resolves
to an internal address. Only an allowlist stops that. Pass the origins editors
may use:
emdash({
plugins: [
statusbeam({
allowedOrigins: ['https://status.example.com', 'https://cms.example.com'],
}),
],
})- A block whose source URL origin (scheme, host and port) is not listed renders as unavailable and makes no request. The path after the origin is free.
- Origins are compared normalised: case and a default port (
:443,:80) do not matter. - Each entry must be an http(s) origin with no path, query, fragment or credentials. A bad entry fails the site build with an error that names it.
- A listed origin is trusted as configured, even if it is a private address. The credentials, query string and fragment checks still apply.
- An empty or missing list keeps the default behavior above: the literal-address check in production, none on the development server.
Show status on a page
Point the components at either source with baseUrl and flavor. Use
flavor="standalone" for a standalone deployment (the default) and
flavor="emdash" for the plugin on the same site:
---
import { SiteDetail, StatusBanner, UptimeBars } from '@statusbeam/emdash-ui'
const source = { baseUrl: 'https://status.example.com', flavor: 'standalone' as const }
---
<StatusBanner {...source} />
<SiteDetail {...source} slug="api" />
<UptimeBars {...source} slug="api" />Every status uses a text label or an icon as well as color. A response that fails
the contract renders an “unavailable” state instead of throwing, so a status
source that is down never breaks your page. To reuse one request for several
components, call fetchStatus from the same package and pass its result prop.