Manual Recipes Status Indicators

Status Indicators

elements man recipes/status-indicators Read as markdown

A 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 serviceStatuses table; the route reads it for initial render, the channel still drives live updates.
  • Triggers for setStatus. Anything server-side can call it: an app.cron health-check loop, a webhook from your monitoring vendor (a POST /webhook/pagerduty route that parses the body and calls setStatus(...)), 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 a HealthCheckJob.run() that pings each service (or hits an internal endpoint) and calls setStatus. The page picks up every change through the channel without any extra wiring.
  • Per-tenant status pages. Add tenantId to StatusEvent and 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.