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

The trick is to decouple collection from display. The browser should not be the thing that discovers state, because the moment it is, your status page depends on a working API, on CORS behaving, 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. No polling code, no websocket, no bundle to download before the page can tell you anything. For this one job it is precisely the right tool, and it has the pleasant property of working in a text browser 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 serves it through an Ingress. That split is the entire point. An nginx pod serving a static file is about as close to unkillable as a web service gets, 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 on the page makes that obvious at a glance. The failure mode is a slightly old photograph, not a blank screen or a spinner that never resolves.

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, which is what makes the page survive its own backend.

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. If you have built a status page that survives its own backend going down, I would like to see where you drew the line.