Step 5 re-pass under the strengthened evidence gate. The 14-source count was the doc's own opening framing claim but had no evidence attached - I'd verified it live earlier this session (grepping the real function for every upstream host it calls) but never actually showed the work. Added it.
113 lines
10 KiB
Markdown
113 lines
10 KiB
Markdown
# 🖥️ Dashy SOC Board
|
|
|
|
> [!NOTE]
|
|
> The homelab's "one pane of glass" — a single Dashy page backed by one large custom Node-RED function, aggregating fourteen distinct data sources that used to be scattered across separate pages into one glanceable board. This is where every other doc in this repo actually converges visually — if a system produces a signal worth watching, it shows up here.
|
|
|
|
## Fourteen data sources, one board
|
|
|
|
This board didn't start from nothing — it absorbed several formerly-standalone Dashy pages (a Dependency Tiers page, a Wazuh overview, an Authentik page, Kestra/n8n execution health) as per-host expandable sub-sections instead. There's no separate Wazuh-only or Dependency-Tiers-only page anymore; both live only inside this one board now. A representative slice of what each source actually contributes, confirmed by reading the real function's source, not paraphrased from what it's assumed to do:
|
|
|
|
| Source | What it actually contributes |
|
|
|---|---|
|
|
| Wazuh indexer | 7-day findings, categorized: ClamAV, vulnerabilities, rootkit/anomaly, authentication, other |
|
|
| Vault | Seal state, active lease/token counts, KV engine counts, token TTL distribution, and root/intermediate CA cert expiry — parsed live from Vault's own `pki/cert/ca` and `pki_int/cert/ca` endpoints on every load, not a cached value |
|
|
| Technitium DNS | Query stats, top clients/domains, blocklist state, plus a dedicated "DNS Security Signals" section: forwarder failures (read from the Loki-shipped query log), query-log ingestion lag, next blocklist update time |
|
|
| Fleet config-drift audit | *Not* computed by this board's own function — read from Node-RED's shared `fleetAudit` global, populated by a separate ingestion process, covering 4 finding types: untracked stacks, missing declared directories, containers stuck in `Created`, and TZ drift |
|
|
| Slack | 12 app-specific channels (the *arr suite, Jellyfin/Emby/Seerr, Kopia, Authentik, Gitea, Netbox, Grafana) mapped to their owning host by a plain lookup table, plus each host's own alert channel |
|
|
| Proxmox `cluster/resources` API | The two hypervisor nodes' own CPU/RAM/disk (no Prometheus coverage on bare metal) plus the full running VM/LXC tree per node |
|
|
|
|
The fourteen, enumerated directly from the function's own upstream calls rather than counted by hand:
|
|
|
|
```
|
|
$ grep -oE 'https?://[a-zA-Z0-9._-]+\.[service-to-service zone]' soc_board_func.js | sort -u
|
|
authentik, hyper1 (Proxmox API), kestra, loki, n8n, pbs, prometheus,
|
|
technitium, vault, wazuh-api, wazuh-indexer → 11 distinct hosts
|
|
+ slack.com → 12
|
|
+ fleetAudit (Node-RED global context, no HTTP call — see table above) → 13
|
|
+ pfSense (own dedicated collector, folded into the Prometheus query) → 14
|
|
```
|
|
|
|
This is the real function, not a claimed size — one single Node-RED function, not several smaller ones stitched together:
|
|
|
|
```
|
|
$ python3 -c "
|
|
import json
|
|
data = json.load(open('flows.json'))
|
|
for n in data:
|
|
if n.get('type') == 'function' and n.get('name') == 'Build SOC Board HTML':
|
|
print(n['name'], '->', len(n['func']), 'chars')
|
|
"
|
|
Build SOC Board HTML -> 104443 chars
|
|
```
|
|
|
|
## Why one function instead of native Dashy widgets
|
|
|
|
Dashy's own built-in widget types cover simple cases well, but none of them can join data across sources the way this board needs to — a per-host tile that combines Wazuh findings, Prometheus resource usage, and Slack alert history into one expandable panel isn't expressible as a single native widget config. Building it as one Node-RED function that does the aggregation server-side, then hands the dashboard pre-joined data, was the tradeoff made: a large, custom function to maintain, in exchange for a board that actually reflects cross-system state instead of fourteen disconnected tiles a human has to mentally correlate themselves.
|
|
|
|
## 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 (a Vault seal-state panel on the secrets host, a Wazuh compliance ring on any host, and so on), and findings grouped by category.
|
|
|
|
This is the real board, not a mockup of the layout — the dependency ladder, the Wazuh 24h KPIs, the Active Issues strip (clean here — no critical/serious findings at the time this was captured), and the per-host tile grid with real sparklines and error counts:
|
|
|
|

|
|
|
|
> [!TIP]
|
|
> **Deliberately scoped**: security/error/warning signal only. Per-app uptime is handled separately by Dashy's own native status checks on individual tiles — the only up/down signal inside this board is *infrastructure-tier* reachability from Prometheus, not per-app availability. Keeping the scope narrow — resisting the temptation to also make this the uptime dashboard — is what keeps the board actually scannable instead of turning into a second copy of Grafana.
|
|
|
|
## Every finding ships with a copy-ready incident summary
|
|
|
|
Each finding's expanded row has a "Copy Debug Prompt" button, not just an acknowledge control. Clicking it builds a fully-formed summary from that specific finding's own host, severity, title, and detail, and copies it straight to the clipboard — a complete, structured starting point for triage without manually re-gathering context from the board itself. This is the real template:
|
|
|
|
```
|
|
Host: {hostId}
|
|
Severity: {severity}
|
|
Issue: {title}
|
|
Detail: {meta}
|
|
|
|
{body}
|
|
```
|
|
|
|
|
|
## Acknowledge mechanism: client-side, no backend
|
|
|
|

|
|
|
|
This is the real key format and storage, confirmed directly against the function source, not a description of the design:
|
|
|
|
```
|
|
const key = `${hostId}::${group}::${it.title}`;
|
|
...
|
|
localStorage.getItem("socAckedV2")
|
|
```
|
|
|
|
> [!IMPORTANT]
|
|
> Acknowledging a finding is a **display filter only** — it never writes back to Wazuh, Slack, or any upstream source described elsewhere in this repo, is scoped per-browser rather than fleet-wide, and a full data set for every host is always kept in memory (in inert template elements, not just whichever panel is currently open) 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. This was a deliberate tradeoff favoring simplicity — no backend state to build, secure, or back up — over persistence, made because the board's job is triage visibility, not an audit trail of who acknowledged what (that's what Wazuh's own alert log in [`docs/security-monitoring.md`](security-monitoring.md) is for).
|
|
|
|
## 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` + `innerHTML` means 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 given fourteen upstream calls.
|
|
> - Gitea's own package-list API hard-caps results per page regardless of a higher requested limit — real pagination has to be implemented, not assumed away, or the registry data from [`docs/registry-and-build.md`](registry-and-build.md) silently truncates.
|
|
> - Node-RED's Mustache templating in a core node's URL field is scoped to `msg` directly, not `msg.msg` — the correct reference is `{{{url}}}`, not `{{{msg.url}}}`, and it needs the triple-brace form specifically for values containing characters like `&` that the double-brace form would HTML-escape. Get either wrong and the request silently goes to an empty or mangled URL instead of erroring.
|
|
> - 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, the same gotcha that shows up in [`docs/registry-and-build.md`](registry-and-build.md)'s redeploy path.
|
|
|
|
## 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 — the same design instinct behind this repo's own docs, honestly:
|
|
|
|
| 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, and a browser can't hold the credentials each one needs. One exception: a public poster CDN gets hit directly, since no auth or proxying is needed there.
|
|
|
|
---
|
|
Related: [`docs/architecture-overview.md`](architecture-overview.md), [`docs/security-monitoring.md`](security-monitoring.md), [`docs/registry-and-build.md`](registry-and-build.md), [`docs/cron-scheduler.md`](cron-scheduler.md) · Stacks: [`stacks/platform/dashy/`](../stacks/platform/dashy/), [`stacks/platform/node-red/`](../stacks/platform/node-red/)
|