Status Indicators
elements man recipes/status-indicators Read as markdownA status page that shows a colored dot per monitored service ("api", "database",
"email", "cdn"). A health-check process (a cron job, a webhook handler, an admin
form) calls a setStatus rpc; the rpc broadcasts on a Channel. Every browser
on the status page receives the broadcast and re-colors the dot for the matching
service. No table, no LiveTable. The latest broadcast wins, and the page
reflects it.
This is a pure-channel pattern. Status is ephemeral state: clients care about the current value, not the history. The recipe lives entirely in one page directory plus the channel definition.
Page setup
elements create page status
The service names, the status union, the channel, and the setStatus rpc are
used only by the /status page (and whatever external triggers call the rpc),
so they live in the page's own services.ts. The route loads them and hands a
listener to the template.
app/pages/status/services.ts:
import { Channel } from "@elements/app";
export type ServiceName = "api" | "database" | "email" | "cdn";
export type Status = "ok" | "degraded" | "down" | "unknown";
export interface StatusEvent {
name: ServiceName;
status: Status;
}
export const SERVICES: ServiceName[] = ["api", "database", "email", "cdn"];
export const statusChannel = new Channel<StatusEvent>("service-status");
/** @rpc */
export function setStatus(name: ServiceName, status: Status) {
statusChannel.notify({ name, status });
}
The SERVICES constant is the list of monitored services. The channel carries
StatusEvent payloads: a service name and its new status. setStatus is the
only rpc; it's the universal entry point that whatever runs health checks (a
cron, a webhook, manual admin action) calls when a status changes.
app/pages/status/index.ts:
import status from "./template";
import { statusChannel, SERVICES } from "./services";
export default function route(req, res) {
return new status({
services: SERVICES,
listener: statusChannel.listen(),
});
}
The route returns the list of services plus a listener attached to the status
channel. The first paint shows every service with unknown status; the listener
updates them in place as broadcasts arrive.
app/pages/status/template.html:
import "./style.css";
import type { Listener } from "@elements/app";
import { ServiceName, Status, StatusEvent } from "./services";
function onIncoming(event: StatusEvent, statuses: { value: Record<ServiceName, Status> }) {
statuses.value = { ...statuses.value, [event.name]: event.status };
}
function statusFor(name: ServiceName, statuses: Record<ServiceName, Status>): Status {
return statuses[name] ?? "unknown";
}
<html class="status"
(services: ServiceName[],
listener: Listener<StatusEvent>,
private statuses: { value: Record<ServiceName, Status> } = { value: {} as Record<ServiceName, Status> })
oninit={() => listener.on("notify", (e) => onIncoming(e, statuses))}>
<h1>service status</h1>
<ul class="services">
<li e:for={name of services} class={`service status-${statusFor(name, statuses.value)}`}>
<span class="dot"/>
<span class="name">{name}</span>
<span class="state">{statusFor(name, statuses.value)}</span>
</li>
</ul>
</html>
statuses.value is a Record<ServiceName, Status>. onIncoming reassigns the
whole object ({ ...statuses.value, [name]: status }) so the binding re-runs
and the row's class flips. The class={"service status-" + statusFor(...)}
interpolation maps the current status to a CSS class (status-ok,
status-degraded, status-down, status-unknown), which is where the dot
color lives.
statusFor falls back to "unknown" for services without a broadcast yet.
That's the initial state on every fresh page load.
app/pages/status/style.css:
@import "#app/shared/styles/page.css";
.status ul.services {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-3);
max-width: var(--container-prose);
}
.status .service {
display: grid;
grid-template-columns: 12px 1fr auto;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--rule);
border-radius: var(--radius-md);
}
.status .dot {
width: 12px;
height: 12px;
border-radius: var(--radius-full);
background: var(--ink-faint);
}
.status .status-ok .dot {
background: var(--green);
}
.status .status-degraded .dot {
background: var(--yellow);
}
.status .status-down .dot {
background: var(--red);
}
.status .name {
font-weight: var(--font-medium);
}
.status .state {
color: var(--ink-muted);
font-size: var(--text-sm);
}
The dot color comes from the status-* class on the parent <li>. Adding a new
status ("maintenance") means three changes: the Status union, a new
.status-maintenance .dot rule, and any UI text that lists the legend.
Routes
Register the page in index.ts:
import status from "#app/pages/status";
// ...
app.route("/status", status);
Notes
- Initial state is "unknown". On a fresh page load, no broadcasts have
arrived yet, so every service renders as gray. The first health-check tick
populates the real values. For a friendlier first paint, persist the latest
status per service in a
serviceStatusestable; the route reads it for initial render, the channel still drives live updates. - Triggers for
setStatus. Anything server-side can call it: anapp.cronhealth-check loop, a webhook from your monitoring vendor (aPOST /webhook/pagerdutyroute that parses the body and callssetStatus(...)), an admin button in another page. The recipe stays decoupled from the trigger. - Cron health check. Add an
app.cron("every 30s", "check services", () => new HealthCheckJob().schedule())and aHealthCheckJob.run()that pings each service (or hits an internal endpoint) and callssetStatus. The page picks up every change through the channel without any extra wiring. - Per-tenant status pages. Add
tenantIdtoStatusEventand filter the listener:statusChannel.listen({ filter: (e) => e.tenantId === currentTenantId }). The same broadcast channel handles every tenant; the filter splits the stream. - Why no LiveTable. LiveTable broadcasts on row insert/update/delete. Status events have no row identity to track (the latest value replaces the previous one, there is no history). The channel matches what the recipe needs: notify the new value, listen, replace local state.