All posts

SENTINEL: a status page that ships zero JavaScript

SENTINEL is my homelab status page, and it ships zero JavaScript. Health is collected out of band, baked into static HTML every minute, and served from K3s. A status page should be the one page that still loads when everything else is broken.

Most status pages are single-page apps that boot a JavaScript bundle and then poll an API from your browser to discover what is up. That has always struck me as backwards. The status page is the page you load specifically when things are broken, so it should depend on as little as possible. SENTINEL, the status page for my homelab, takes the opposite position: it ships zero JavaScript. The browser receives finished HTML, baked a minute ago, and nothing else. It also happens to look like an amber CRT, because if I am going to stare at it during an outage it may as well be pleasant.

SENTINEL SENTINEL // homelab status SERVICE STATUS 90-DAY UPTIME proxmox-cluster UP 99.98% nextcloud UP 99.95% k3s-ingress UP 100% jellyfin DEGRADED 98.71% gitea UP 99.90% restic-backups UP 99.99% updated 41s ago · baked at build time · 0 KB JavaScript
The page itself: monospace, amber, a glow and scanlines done entirely in CSS. A green dot is an old habit, but the whole palette lives on the amber side.

A status page is a photograph, not a video

So collection and display have to be decoupled. The browser should not be the thing that discovers state, because the moment it is, your status page depends on a working API, on a correct cross-origin (CORS) policy, on a JavaScript runtime, and on the user's network all cooperating at exactly the time something is already wrong. So instead a collector runs on a schedule, probes each service, and writes the results to a small JSON file, and a template step bakes that JSON into index.html. The page the browser receives is a photograph of the system as of the last run, not a live feed it has to assemble. The cost is staleness, bounded by how often the collector runs, and the page prints its own age so that staleness is never hidden.

Refreshing without JavaScript

Keeping the photograph current needs exactly one line, and it is the oldest trick on the web:

<meta http-equiv="refresh" content="60">

The browser re-fetches the page every sixty seconds and gets whatever the collector last baked. There is no polling code and no websocket, and nothing to download before the page can tell you anything. It works in a text browser, and on a phone with one bar of signal.

Phosphor amber in pure CSS

The CRT look is all CSS, with no images and no canvas. The glow is a stack of text-shadows in the amber. The scanlines are a repeating-linear-gradient laid over everything at low opacity. A status is a colored span and an uptime bar is a background gradient. Because there is no JavaScript, the entire page is a few kilobytes of HTML and one stylesheet.

:root { --amber:#f0a93a; }

.glow {
  color: var(--amber);
  text-shadow: 0 0 2px var(--amber), 0 0 8px rgba(240,169,58,.45);
}

/* scanlines, drawn over the whole page */
body::after {
  content: ""; position: fixed; inset: 0; pointer-events: none;
  background: repeating-linear-gradient(
    0deg, rgba(0,0,0,.16) 0 1px, transparent 1px 3px);
}

Where it lives, and why it fails gracefully

There are two pieces on the cluster. A CronJob runs the probe-and-template step every minute and writes index.html to a small persistent volume. An nginx Deployment mounts that same volume read-only and exposes it through an Ingress, the Kubernetes resource that publishes a service to the outside. An nginx pod serving a static file has very little reason to fall over, and it has no dependency on the collector being alive. If the collector dies, the page does not go down, it goes stale, and the timestamp makes that obvious at a glance. You get a slightly old photograph instead of a blank screen.

apiVersion: batch/v1
kind: CronJob
metadata: { name: sentinel-collect }
spec:
  schedule: "* * * * *"            # every minute
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: collect
              image: registry.kerboul.me/sentinel:latest
              volumeMounts:
                - { name: site, mountPath: /out }   # writes index.html here
          volumes:
            - name: site
              persistentVolumeClaim: { claimName: sentinel-site }
# nginx mounts the same PVC read-only and serves /out. It never calls the collector.
collected out of band · every 60s · may die without taking the page down always-on path · static · 0 JS services proxmox · docker · k3s collector CronJob * * * * * PVC index.html (baked) nginx serves static browser refresh 60s · 0 JS nginx depends on a file, not on the collector being alive.
Collection and serving never touch each other directly. They meet at a file on a volume.

The next iteration writes a small history file so the uptime bars cover the last ninety days rather than just now, still baked at build time, still zero JavaScript on the client. The known limit: if the CronJob fails several times in a row, the page goes stale with nothing but the page itself to say so.