Browser ──HTTP/JSON──┐
│ ├──> seid (Go) ──SQL──> MySQL (statusengine_*, sei_*)
└──SSE────────────┘ │
├──POST /commands──> worker :8081 (external commands)
└──WS /ws────────> worker :8080 (live events)
One process serves the API and the Angular bundle. Go, because the Statusengine worker is Go, the deployment stays a single file, and the event consumer plus fan-out belongs in a long-lived process rather than in a request handler.
The browser never talks to the worker. Two reasons, and the second is the one that decides it:
- The worker's keys would have to reach the page, and a key in the page is a key in everybody's hands.
- Browser JavaScript cannot set headers on a WebSocket handshake, so a
browser client would be pushed onto the
?api_key=query parameter - which the worker's own documentation calls a browser fallback, and which leaks into proxy logs and history.
So seid holds one authenticated WebSocket to the worker, subscribes to
the topics the UI actually uses, and fans them out to browsers over SSE
with the ordinary session cookie.
| Package | Responsibility |
|---|---|
internal/config |
Defaults, YAML, environment, flags, validation |
internal/database |
The MySQL pool |
internal/migrate |
Embedded sei_* schema, applied under an advisory lock |
internal/auth |
argon2id, sessions, roles, permissions |
internal/httpapi |
Routing, middleware, handlers, static bundle |
internal/domain |
Types shared across repositories and handlers |
internal/repository/mysql |
Every query against statusengine_* |
internal/metrics |
Provider interface; MySQL now, Graphite later |
internal/commands |
Naemon command construction, worker client, audit |
internal/events |
Worker WebSocket consumer, hub, SSE endpoint |
internal/webui |
The embedded Angular bundle |
Three interfaces carry the parts that are meant to be replaceable:
metrics.Provider— the worker can already route performance data to Graphite instead of MySQL, so this abstraction has a real second implementation waiting rather than being a speculative one.commands.Transport— the worker's HTTP endpoint today; replaceable with a direct Gearman publish or thenaemon.cmdpipe, and with a fake in tests.events.Source— the worker's WebSocket today; a polling source covers the case where it is unreachable.
The history tables are clustered on an object-first primary key:
| Table | Primary key |
|---|---|
statusengine_servicechecks |
(hostname, service_description, start_time, start_time_usec) |
statusengine_service_statehistory |
(hostname, service_description, state_time, state_time_usec) |
statusengine_hostchecks |
(hostname, start_time, start_time_usec) |
statusengine_host_statehistory |
(hostname, state_time, state_time_usec) |
InnoDB stores rows in primary-key order, so every check and every state change for one service sits physically together, in time order. That is the point of the key, and it decides the shape of this interface's history queries:
- One object over a window is a contiguous range read. Measured at 8 ms against 321k rows. This is the service detail page's history tab, and it is the common case.
- Every object over a window is not. The rows for a given hour are scattered across the whole table, one small run per service. Measured at 244 ms against 321k rows, and it grows with the table rather than with the window.
So the global check-history and state-change pages lead with an object filter and cap the window, rather than pretending an unfiltered year is a reasonable request. Every history endpoint requires a window and defaults to a short one. The per-object pages, which is where an operator actually spends an incident, are on the fast path by construction.
Adding a time-leading index to these tables would work against the clustering rather than with it, and is not something this interface asks for.
Two indexes in the standard schema are worth naming, because the queries here are written to hit them:
statusengine_servicestatuscarriesissues (problem_has_been_acknowledged, scheduled_downtime_depth, current_state), which is exactly the shape of the unhandled-problems filter that the dashboard and the problems list are built around.statusengine_perfdatacarriesmetric (hostname, service_description, label, timestamp_unix). The chart query is written in exactly that column order and downsamples in SQL:
SELECT label, unit,
(timestamp_unix DIV ?) * ? AS bucket,
AVG(value), MIN(value), MAX(value)
FROM statusengine_perfdata
WHERE hostname = ? AND service_description = ?
AND timestamp_unix BETWEEN ? AND ?
GROUP BY label, unit, bucket
ORDER BY bucketThe standard schema, packaging/mysql_schema.sql in the worker
repository, has no partitions. Some installations add them, and this
project does not rely on them either way. If you do partition by
time DIV 86400, be aware that MySQL does not prune for that
expression - EXPLAIN reports every partition for a windowed query - so
the clustering above is still what carries the cost.
Sorting is the one place a query parameter would otherwise reach SQL as an
identifier, where placeholders cannot help. Every list endpoint declares a
whitelist of sortable columns, and anything outside it is a 400 naming
the allowed values. Everything else - filters, search, pagination - is a
bound parameter.
The worker's /commands endpoint takes the broker's own envelope, with
Command and Data capitalised. Three operations have typed forms
(schedule_check, check_result, delete_downtime); everything else goes
as raw, a Naemon external command line, and the worker prefixes the
timestamp.
The author of a command always comes from the session, never from the
request body. Every field is checked for ;, newlines and control
characters before assembly - the worker rejects control characters itself,
but a ; inside a comment would silently shift every field after it.
A 202 means the command reached the broker, not that Naemon ran it.
Gearman acknowledges queueing; the broker has no reply path. So the API
returns 202 with the audit record written, and the UI shows "submitted",
then watches the affected object for a few seconds - via SSE, or by polling -
before it says "confirmed". When confirmation does not arrive, it says that
instead of claiming success.
The audit is written by the same path, one row per object rather than one
per submission, and refusals are written too: "who tried to acknowledge
that, and why did it not take" is a question a log of successes cannot
answer. GET /api/v1/commands/audit reads it back behind audit:read,
which admins and operators hold and guests do not.
Angular 21, standalone components, signals, zoneless change detection. Tailwind v4 for the design tokens, Angular CDK for overlay and accessibility primitives. No component library: the visual language here is dense operational tables, which is not what a general-purpose kit is shaped for.
Two rules run through the styling:
- State is structure. A status is carried by a rail at the row's leading edge, a label, and the sort order - not by colour alone. The rail is solid for a hard state and striped for a soft one, which is the distinction that decides whether an operator acts now or waits.
- Monospace means a machine wrote it. Hostnames, service descriptions, plugin output, performance data, command lines. Everything a person wrote is sans. An operator can tell a literal string from our prose without reading it.
Fonts are vendored rather than fetched from a CDN. A monitoring system routinely runs on a segment with no route to the internet, and a UI that degrades there degrades exactly when someone needs it.
Angular's critical-CSS inliner is switched off in the production build: it
emits <link ... onload="this.media='all'">, an inline event handler that
our script-src 'self' policy blocks, which would leave the real
stylesheet unloaded. The inliner saves one round trip on a same-host
deployment; the policy is worth more.