brainstate

BrettKinny/tower-dashboard · source

Snapshot 0 · 2026-11-04T12:47:28Z · Shell · MIT

Content fingerprint19e871f2d3a584a0b2893abd1a2a99322413bf4407e5a2aadc833bfc9454a789

GitHub description at capture

TUI monitoring dashboard for Unraid: btop, nvtop, ctop, and fan-status composed in a single tmux window over SSH, launchable from any XDG app launcher.

README at capture

<p align="center">
  <img src="workstation/tower-dashboard.svg" alt="Tower Dashboard icon" width="128" height="128">
</p>

# Tower Dashboard: a TUI for monitoring your Unraid box

A live, read-only monitoring dashboard for **Unraid**: `btop`, `nvtop` and
`ctop` composed in a tmux session that runs **on the box itself**, with fans,
CPU temp and the clock in a lazygit-style footer. The workstation just attaches
to it over a single SSH connection, launchable from any XDG app launcher. One
click, one keystroke, one window — everything you need to glance at to know
the box is healthy.

On [omarchy](https://omarchy.org) it's also a **bar plugin**: an icon that
turns red when something on the box needs attention (a disabled disk, a full
or hot drive, parity errors, an unhealthy container, a stalled fan), a popup
with the details, and a right-click into the full dashboard.

![Tower Dashboard screenshot](https://github.com/user-attachments/assets/f715879b-fd18-4847-b6df-f2c1da0fc7c2)
<sub>Running in `foot` on [omarchy](https://omarchy.org) with the etherial theme.</sub>

> **Built for Unraid.** The tower-side pieces are Unraid-coupled (`mdcmd`,
> emhttp's `disks.ini`, `/mnt/disk*` / `/mnt/cache` / `/mnt/user`, RAM-FS
> restored from `/boot/config/go`). The workstation-side launcher will happily
> SSH into any Linux host, but the array-status and boot-restore bits assume
> Unraid.

> **Shared as a starting point, not a finished product.** Fork it, rip out
> what doesn't match your box, rewire the rest. The generalizable bits — the
> tmux composition pattern, the hwmon-via-sysfs fan reader, the
> FAT32-survives-boot install dance via `/boot/config/go` — are the parts
> worth lifting even if your stack looks nothing like mine.

## Layout

Two rows plus a footer, all running on the Unraid host inside a server-side
tmux session. Each panel is titled in its top border, lazygit-style, with the
focused one picked out in green:

| Region | Content | Source |
|---|---|---|
| `system` — top ~65%, full width | `btop --force-utf` — CPU/cores/load, mem, usage + live R/W per array disk, eth0 net, processes | upstream btop |
| `gpu` · `containers` — bottom ~35%, 50/50 | `nvtop` (left) · `ctop -a -s cpu` (right, running containers: CPU, mem, PIDs) | upstream |
| Footer — tmux status bar | box name + IP · `tower-health` light (● healthy / ▲ first problem) · `fan-status --line` (intake/exhaust %+RPM, CPU temp) · clock, every 2s | this repo |

Colours are terminal-palette indices throughout (btop's `TTY` theme, tmux
`colourN`), so the whole thing follows your local terminal's theme.

Anything interactive (lazydocker, lazygit, etc.) was deliberately moved out —
this view is pure observability.

## How it works

The dashboard is a tmux session named `dash` that lives **on the Unraid box**:

- **`tower/bin/tower-dash`** (on the box) builds that session — the layout,
  each pane running its monitor in a restart loop — then attaches. It builds
  once; every later run re-attaches to the warm session.
- **`workstation/tower-dashboard`** (on the workstation) is the launcher: it
  spawns a fresh terminal that runs `ssh -t <box> tower-dash`. That's the whole
  client side — one SSH connection, no client-side tmux, no layout logic. On
  Hyprland, launching again focuses the open dashboard window instead of
  opening a second; the window's app-id is `tower-dashboard`, for window rules.
  If SSH fails, the window stays open to show why.

Because the session lives on the box, the monitors stay warm between launches
and survive the workstation disconnecting — re-opening is instant. The session
lasts until the box reboots (or until `tower-dash kill` ends it sooner;
`tower-dash rebuild` restarts it to pick up config changes).

The **omarchy bar plugin** (`manifest.json` + `omarchy/`) polls the box with
[`tower/bin/tower-status`](tower/bin/tower-status), a read-only probe piped
over one non-interactive SSH request (`ssh <box> bash -s < tower-status`), so
it works without installing anything new on the box. `omarchy/status.py`
turns the probe's records into JSON and decides what needs attention.

> Earlier versions built the layout client-side and drove it over `send-keys`
> at attach time, then carried a 1-row fan-strip pane that needed resize hooks
> to stop it scaling into a fat band. Building on the box and moving the strip
> into tmux's status bar removed all of that.

## Why this over the alternatives?

- **vs. the Unraid web UI** — the WebGUI lives behind a tab and refreshes
  slowly. This is one keystroke ("Tower Dashboard" in your launcher) and
  updates in real time. No browser, no bookmark, no auth.
- **vs. Glances / Beszel / Netdata** — those are excellent dashboards that
  want a daemon on the box, a port, and (usually) a browser. This is no web
  daemon and no port: just `tmux` plus the TUI tools you'd run by hand anyway.
  Read-only by design.
- **vs. `ssh root@tower htop`** — htop is one pane. This is the panels you
  actually want, laid out at the right sizes, with the right tool for each
  (btop for storage I/O, nvtop for GPU, ctop for containers, custom script for
  fan PWM).

## Repo layout

```
manifest.json        # omarchy plugin manifest (the repo root is the plugin)
omarchy/             # omarchy bar plugin
  TowerWidget.qml      # bar icon + popup panel
  Service.qml          # polling
  status.py            # runs tower-status over SSH → JSON snapshot + health rules
  test_status.py       # unit tests (python3 -m unittest)
workstation/         # Files that live on the laptop/desktop
  tower-dashboard      # launcher — spawns a terminal, SSHes in, attaches
  tower-dashboard.svg  # icon — symlinked into ~/.local/share/icons/.../apps/
  Tower Dashboard.desktop.in  # XDG launcher entry template (Exec=@BIN_PATH@)
  ssh-config.snippet   # optional SSH ControlMaster block for ~/.ssh/config
tower/               # Files that live on the Unraid host
  bin/                 # tower-dash, tower-status, tower-health, fan-status, array-status
  config/              # btop.conf, ctop config, sensors-ignore-fans.conf
  go.snippet           # boot-restore lines for /boot/config/go
  README.md            # Unraid-side details
install-workstation.sh # Idempotent installer for the workstation side
install-tower.sh       # Idempotent installer for the tower side (over SSH)
LICENSE              # MIT
```

## Install

You need key-based (non-interactive) SSH from the workstation to the box first —
the plugin and launcher both rely on it.

### Omarchy

```bash
omarchy plugin add https://github.com/BrettKinny/tower-dashboard --enable
```

That puts the icon in the bar (right section by default). Set the SSH target
in the widget's settings if your box isn't `root@tower.local`. Then:

- **Left-click** — popup: array + pool disks (usage, temps, errors), parity
  check status, CPU/RAM/GPU, fans, containers. `R` refreshes, `D` / `Enter`
  opens the dashboard, `Esc` closes.
- **Right-click** — open the dashboard. **Middle-click** — refresh.
- **Notifications** — a desktop notification when a problem appears (critical,
  so it stays until dismissed) and a quiet one when it clears. Each problem is
  announced once — a disk going from 91% to 92% full isn't news — and the box
  has to be unreachable for three polls in a row before that's announced, so
  sleep/wake and Wi-Fi blips stay silent. Toggle with the *Notifications*
  setting; state lives in `~/.local/state/tower-dashboard/notified.json`.
- **IPC** — `omarchy-shell brettkinny.tower-dashboard dashboard` (also
  `toggle`, `refresh`, `status`), e.g. for a Hyprland keybinding.

Update with `omarchy plugin update brettkinny.tower-dashboard`, then
`omarchy restart shell`: the shell's hot reload picks up the widget but keeps
a cached copy of `omarchy/Service.qml` (a same-folder QML type), so polling
changes only take effect after a restart.

The plugin brings the launcher with it, so you only need
`install-workstation.sh` too if you also want the "Tower Dashboard" app
launcher entry and the `unraid-dash` alias.

### Workstation (any desktop)

```bash
git clone https://github.com/BrettKinny/tower-dashboard ~/Repos/tower-dashboard
~/Repos/tower-dashboard/install-workstation.sh
```

The installer is idempotent (safe to re-run): it symlinks the launcher script
and icon into the right XDG dirs, renders the `.desktop` entry from its
template with an absolute `Exec=` path (some launchers — notably walker on
omarchy — don't resolve `~/.local/bin` from unqualified Exec lines), adds an
`unraid-dash` shell alias to `~/.bashrc`, and refreshes the desktop database
so launchers pick up the new entry. If you move the checkout, re-run it — the
symlinks point into the checkout.

### Tower (Unraid)

1. Install **tmux** (NerdTools plugin) and **nvtop** (nvtop plugin) from the
   Unraid Apps tab.
2. Drop static `btop` + `ctop` binaries at `/boot/config/bin/`.
3. Run `./install-tower.sh [root@your-box]` from the workstation. It copies the
   scripts and configs onto the flash and into their live locations, appends
   any missing [`go.snippet`](tower/go.snippet) lines to `/boot/config/go`
   (review them first — the fan lines are specific to my board), and builds
   the `tmux-256color` terminfo onto the flash if needed. Add `--rebuild` to
   restart the warm `dash` session with the new bits.

See [`tower/README.md`](tower/README.md) for what goes where and why.

## Customize

Everything machine-specific is reachable via env vars or plugin settings, so
you should rarely need to fork.

### Workstation side (set in `~/.bashrc` or shell rc)

| Var | Default | What it does |
|---|---|---|
| `TOWER_HOST` | `root@tower.local` | SSH target the launcher (and `install-tower.sh`) connects to. The bar plugin uses its own *SSH target* setting. |
| `TERMINAL` | autodetected | Override terminal autodetection (otherwise tries `xdg-terminal-exec`, `ghostty`, `kitty`, `alacritty`, `foot`, `wezterm`, `gnome-terminal`, `konsole`, `xterm` in that order) |

### Plugin health thresholds

What counts as "needs attention" lives at the top of
[`omarchy/status.py`](omarchy/status.py): disks ≥ 90% full, array disks ≥ 50°C,
pool devices ≥ 70°C, CPU/GPU ≥ 85°C, plus any disabled/missing array disk,
unmounted pool, read errors, parity-check errors, stalled fan, unhealthy or
restarting container, or a stopped array. Empty slots (including an
unassigned parity2) are ignored.

### Tower side — `fan-status` hardware mapping

`fan-status` defaults match an Asus board with NCT6779 superio + Intel
coretemp + 3-intake/2-exhaust fan wiring. To adapt:

```bash
ssh root@tower.local fan-status --detect   # lists all hwmon chips + their pwm/fan/temp inputs
```

Then export overrides on the tower side (e.g. in `/etc/profile.d/`), or edit
the defaults at the top of the script:

| Var | Default | Notes |
|---|---|---|
| `TOWER_INTAKE_CHIP` | `nct6779` | Chip name (prefix-matched against `/sys/class/hwmon/*/name`) |
| `TOWER_EXHAUST_CHIP` | `nct6779` | Same chip in most setups |
| `TOWER_TEMP_CHIP` | `coretemp` | `k10temp` for AMD |
| `TOWER_INTAKE_NUM` | `1` | `pwm1` + `fan1_input` |
| `TOWER_EXHAUST_NUM` | `3` | `pwm3` + `fan3_input` |
| `TOWER_TEMP_NUM` | `1` | `temp1_input` |

### Tower side — `btop` array disks

Edit `disks_filter` in [`tower/config/btop/btop.conf`](tower/config/btop/btop.conf)
to match your array (Unraid's `/etc/fstab` doesn't list array mounts, so an
explicit filter is required).

## SSH

The dashboard uses a **single** SSH connection — it attaches to the tmux
session on the box. [`workstation/ssh-config.snippet`](workstation/ssh-config.snippet)
has an optional `ControlMaster` block; it's not required, but it makes the bar
plugin's polls near-free (~100 ms instead of a fresh handshake each time). The
launcher pins `TERM=xterm-256color` for the remote tmux, because the box's
RAM-FS won't have terminfo for `xterm-ghostty` / `xterm-kitty` and friends.

## License

MIT — see [LICENSE](LICENSE).