Completes the orchestration/registry/content batch - Kestra's check-pause-approve-apply pattern, the self-hosted build pipeline, the SOC dashboard's 14-source aggregation, the cron heatmap, and the in-progress content pipeline, all in the diagram+callout+table style.
4.8 KiB
🖥️ Dashy SOC Board
Note
The homelab's "one pane of glass" — a single dashboard page backed by one large custom function, aggregating roughly a dozen distinct data sources that used to be scattered across separate pages into one glanceable board.
Fourteen data sources, one board
flowchart TD
subgraph Sources["Data sources"]
direction LR
Wazuh["SOC indexer<br/>+ compliance API"]
Loki["Log aggregator<br/>24h error counts"]
Prom["Metrics<br/>CPU/RAM/disk"]
Slack["Alert channels<br/>per app + per host"]
Orch["Orchestration APIs<br/>execution health"]
Audit["Fleet config-drift<br/>audit feed"]
Hyper["Hypervisor API<br/>VM/LXC tree"]
Vault["Secrets platform<br/>seal state, lease counts"]
SSO["SSO provider<br/>login/failure stats"]
DNS["DNS server<br/>query stats, blocklist"]
Backup["Backup server<br/>job age/status"]
FW["Edge firewall<br/>state table, top rules"]
end
Sources --> Function["One aggregation function"]
Function --> Board["SOC Board<br/>client-rendered"]
Layout
A top row shows a 6-tier infrastructure dependency ladder alongside fleet-wide 24-hour KPIs. Below that, an "Active Issues" strip surfaces the worst findings fleet-wide, followed by a grid of per-host tiles — worst-severity badge, error sparkline, sorted consistently. Clicking a tile expands a full detail panel: compliance and resource gauges, that host's folded-in specialty widget where relevant, and findings grouped by category.
Tip
Deliberately scoped: security/error/warning signal only. Per-app uptime is handled separately by the dashboard's own native status checks on individual tiles — the only up/down signal inside this board is infrastructure-tier reachability, not per-app availability. Keeping the scope narrow is what keeps the board actually scannable.
Acknowledge mechanism: client-side, no backend
flowchart LR
Finding["Finding<br/>stable content-derived key"] --> Ack["User acknowledges"]
Ack --> Storage["Browser localStorage<br/>flat map, per-browser"]
Storage --> Recompute["Live re-derive:<br/>badges, issue strip, gauges"]
Recompute -.never touches.-> Upstream["Wazuh / Slack / any<br/>upstream source"]
Important
Acknowledging a finding is a display filter only — it never writes back to any upstream source, is scoped per-browser rather than fleet-wide, and a full data set for every host is always kept in memory so the recompute has everything it needs regardless of which panel happens to be open. A reload in a different browser shows the finding again — a deliberate tradeoff favoring simplicity (no backend state to manage) over persistence.
Real integration gotchas hit building this
Warning
A few non-obvious lessons from actually building this, in case they save someone else the debugging time:
- Embedding via
fetch+innerHTMLmeans injected<script>tags never execute — each one has to be explicitly re-created on every refresh to keep interactive elements working.- A background poll that could clobber an open detail panel needs to check "is a panel open" both before and after the fetch, since the aggregation endpoint itself can take tens of seconds to respond.
- One backing API hard-caps results per page regardless of a higher requested limit — real pagination has to be implemented, not assumed away.
- Templating engines scoped to the wrong object silently produce an empty string instead of erroring — an easy, quiet failure mode.
- Replacing a bind-mounted config file's content doesn't get picked up by a plain container restart — the container keeps watching the old inode; a full recreate is required.
Widget design: shape follows data, not one template everywhere
Every widget's visual style is chosen to match the shape of the data it shows, not a single card/badge component reused everywhere:
| Data shape | Visual treatment |
|---|---|
| Proportion of a whole (disk %, capacity) | Gauge or bar, not a badge |
| Hierarchy (host containing guests) | Real visual nesting — indentation, a tree — not a flat grid |
| Time-ordered audit/status log | A dense, sortable list ranked worst-first, recency as the dominant signal |
| Named media/content items | A poster/cover-art grid, never a text list |
Tip
The media-poster widgets route through one shared image-proxy endpoint rather than each hitting its own upstream API directly — since these are private, authenticated homelab APIs, not public image CDNs. One exception: a public poster CDN gets hit directly, since no auth or proxying is needed there.
Related stacks: stacks/platform/dashy/, stacks/platform/node-red/