███████╗████████╗██████╗ ██████╗ ██████╗ ███████╗ ██╔════╝╚══██╔══╝██╔══██╗██╔═══██╗██╔══██╗██╔════╝ ███████╗ ██║ ██████╔╝██║ ██║██████╔╝█████╗ ╚════██║ ██║ ██╔══██╗██║ ██║██╔══██╗██╔══╝ ███████║ ██║ ██║ ██║╚██████╔╝██████╔╝███████╗ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
system vitals, sampled in the dark
A strobe light freezes motion by flashing at a fixed interval. strobe does
the same to a machine: once a second it flashes /proc and /sys, catches
every core, disk, NIC, GPU and process mid-stride, and shows you the frame.
It is one collector written in plain Bash and four ways to look at what it
caught: a long-form text report, a bashtop-style terminal UI, a tiny
stdlib-Python backend with a JSON + SSE API, and a single-file animated web
dashboard. No compiled code, no package manager, nothing to pip install on the
box you are worried about. Clone it, run it.
./strobe # the report: health verdict, every core, every disk, top processes
./strobe --tui # live terminal UI: per-core bars, graphs, sortable/filterable processes
./strobe --serve # web dashboard at http://127.0.0.1:8787 (JSON + SSE under /api/)
./strobe --json | jq . # one sample as JSON; add --follow for NDJSON, one line per tickAdd --demo to any of those to see it run on synthetic data, on any OS, right
now.
Trimmed from ./strobe --demo. The whole thing runs about 265 lines across
twelve sections — health summary and the three numbers worth looking at next,
CPU with per-core meters, memory, disks with an I/O table, network with top
talkers, GPU, thermals and power, peripherals (below), processes by CPU /
memory / threads plus per-user totals, services and ports, capacity headroom,
and a footer that records the exact command and knobs that produced it. Colour on a terminal,
plain text in a file or a pipe, pure ASCII when the locale is not UTF-8, and
it fills your terminal when you are reading it and settles at a stable 100
columns when piped or redirected. --width N|auto|fixed (or
STROBE_REPORT_WIDTH) overrides, anywhere from 80 to 220 columns — and a wide
report spends the room on more per-core columns rather than longer bars.
╔══════════════════════════════════════════════════════════════════════════════════════════════════╗
║ strobe // system report demo-box · overall CAUTION ║
╚══════════════════════════════════════════════════════════════════════════════════════════════════╝
Hostname demo-box Report time 2026-08-22 07:19:43 PDT
OS Demo Linux 1.0 Uptime 3d 04:12:10
Kernel 6.9.0-demo Boot time 2026-08-19 03:07:33 PDT
Arch x86_64 Interval 1 s (rates are per second)
Platform demo strobe 2.0.0
━━━━ 1 · HEALTH SUMMARY ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHECK STATUS DETAIL
cpu_load OK load1 2.70 on 8 cores
cpu_usage OK cpu 34% busy
memory OK memory 71% used (22.1 GiB of 31.2 GiB)
swap OK swap 26% used
disk:/ OK / 60% used, inodes 12%
disk:/home OK /home 40% used, inodes 3%
cpu_temp OK cpu 53°C
gpu_temp OK gpu 59°C
failed_services CAUTION 1 failed: bluetooth.service
vpn OK tun0 up at 10.8.0.2
Verdict: CAUTION — 1 caution on demo-box: failed_services (1 failed: bluetooth.service).
━━━━ 2 · CPU ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Model Demo Ryzen 7 8800X 8-Core Processor
Cores 8 logical Clock 3106 MHz avg Temp 53°C OK
Load 2.70 2.36 2.30 (1/5/15 min) per-core ratio 0.33 → OK
Busy 34% user 24% sys 9% iowait 1% steal 0% idle 66% OK
Aggregate [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░········································] 34%
per-core meters (8 cores) ──────────────────────────────────────────────────────────────────────
core usage busy clock
cpu0 [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0% 2200 MHz
cpu1 [███████░░░░░░░░░░░░░░░░░░░░░░░] 23% 2798 MHz
cpu2 [█████████████░░░░░░░░░░░░░░░░░] 44% 3344 MHz
cpu3 [██████░░░░░░░░░░░░░░░░░░░░░░░░] 19% 2694 MHz
cpu4 [█████████████░░░░░░░░░░░░░░░░░] 42% 3292 MHz
cpu5 [███░░░░░░░░░░░░░░░░░░░░░░░░░░░] 11% 2486 MHz
cpu6 [█████████████████████░░░░░░░░░] 71% 4046 MHz
cpu7 [█████████████████████░░░░░░░░░] 69% 3994 MHz
━━━━ 3 · MEMORY ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
… MEMORY, DISKS, NETWORK, GPU, THERMALS & POWER, PROCESSES, SERVICES & PORTS, FOOTER
A real frame, rendered headlessly at 100×30 from ./strobe --tui --demo
(colour stripped; on a terminal the bars and borders carry the theme gradient).
Panels collapse to fit, the process list takes whatever is left, and every
frame is diffed per line so it sips CPU. Spec in docs/TUI.md.
╭─ strobe ⠏ ─ demo-box ─ 6.9.0-demo ─ up 3d 04:12 ─ load 3.74 3.89 4.13 ─ 13 28 30 ────────────────╮
│ 1 CPU ███████████▎░░░░░░░░░░░░ 47% usr 32 sys 13 io 2 st 0 3.4 GHz 57°C │
│c0 █████████████████▎░░░░░░ 72% c1 ███▎░░░░░░░░░░░░░░░░░░░░ 14% │
│c2 █████████▌░░░░░░░░░░░░░░ 40% c3 ██████████████▋░░░░░░░░░ 61% │
│c4 ▍░░░░░░░░░░░░░░░░░░░░░░░ 2% c5 ████████████████████▍░░░ 85% │
│c6 █████████████████▊░░░░░░ 74% c7 ███████▉░░░░░░░░░░░░░░░░ 33% │
│┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ now 47% peak 64%│
│┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈▁▂▂▂▃▄▄▄▄▄▄▄▄▄▃▂▁▁┈┈┈┈┈┈┈│
│┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈▅▆▆█████████████████████▇▇▇▇▆▇│
│ ██████████████████████████████│
├─ 2 MEM ────────────────────────────────────┬─ 3 DISK ────────────────────────────────────────────┤
│used ███████▋░░░░░ 59% 18.4G/31.2G │/ ██████▋░░░░ 61% 290G/476G io 29.1M │
│avail 12.8G cache 6.4G buf 656M shr 302M │/home ████▌░░░░░░ 41% 782G/1.8T io 13.1M │
│swap ███░░░░░░░░░░ 24% 1.9G/8.0G │ ▅▃▆▆█▇█▆▇▅▅▆▄▄ ⇅ all 78.1M/s│
│ ▄▄▄▄▄▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅ peak 62%│ ▇███████████████████▇▇████████│
├─ 4 NET ────────────────────────────────────┴────┬─ 5 GPU · 6 TEMPS · BAT ────────────────────────┤
│▼ 13.2M/s ▲ 1.1M/s VPN tun0 10.8.0.2 │gpu0 ████████▎ 92% 7.0G/23.9G 77°C │
│eth0 10.0.0.42 · wlan0 192.168.1.17 │temps k10temp 57° nvme 41° iwlwifi_1 44° │
│▼ ▆▅▆▆▆▆▆▇███▇▇▆▇▆▆▅▅▅ ▼ 13.8M/s│bat █▎░░░░░░░ 85% ▼ discharging │
│▲ █████████████████▁▂ ▂▂▃▃▃▄▂▃▂▄▄▄▅▄▃▂▂▁ ▲ 2.0M/s│ █▆▅▅▄▄▃ gpu0 now 92% peak 98%│
├─▶ 7 PROC sort:cpu 20/212 ──────────────────────┴────────────────────────────────────────────────┤
│ PID USER CPU%▼ MEM% RSS S THR COMMAND │
│▌ 5120 alice 69.4 ██▊░ 2.0 ▌░░░ 640M R 16 ffmpeg -i input.mkv -c:v libx265 -preset slow o│
│ 1201 postgres 8.4 ▎░░░ 2.6 ▋░░░ 832M R 7 postgres 16/main: checkpointer │
│∙ 1702 root 6.0 ▏░░░ 1.1 ▍░░░ 352M R 8 Xorg vt2 -displayfd 3 -auth /run/user/1000/gdm/│
│∙ 3120 alice 5.5 ▏░░░ 5.8 ▉░░░ 1.8G R 52 firefox -contentproc -childID 3 │
│∙ 5200 alice 5.0 ▏░░░ 0.4 ▎░░░ 128M R 2 make -j8 │
│∙ 2210 alice 4.7 ▏░░░ 3.1 ▋░░░ 992M S 11 node /srv/app/dist/server.js │
│ 1044 root 3.5 ░░░░ 0.8 ▎░░░ 256M S 24 dockerd -H fd:// │
╰─ [q]uit [h]elp [tab] [↑↓] [s/S]ort [/]filter [k]ill [e] [1-9] [+/-] [g] [c] [t] [m] [H] [p] [w] ─╯
In a real terminal it carries the theme gradient — six themes, cycled with c:
Every view also answers "what is attached to this machine, and where" — the monitor and the port it is on, keyboards and mice, webcams, microphones, headsets, USB devices and their bus-port path, Bluetooth peripherals. Real output from a Mac mini:
displays (1) ───────────────────────────────────────────────────────────────
DISPLAY PORT RESOLUTION PRIMARY
LG HDR WFHD DisplayPort 2560x1080@60Hz primary
attached devices (16) ──────────────────────────────────────────────────────
KIND NAME VENDOR PORT STATE
input Magic Keyboard with Touch I… Apple Inc. USB 0x14400000 CONNECTED
input Magic Trackpad - Bluetooth CONNECTED
camera Logitech BRIO Logitech Inc. USB 0x14800000 CONNECTED
audio-in Logitech BRIO - USB IN USE
audio-out PRO X 2 Apple Inc. Bluetooth CONNECTED
capture devices — microphone and camera ─────────────────────────────────────
Microphone Logitech BRIO — IN USE
Camera Logitech BRIO — state unknown (macOS exposes no reliable signal)
A device reads IN USE only when the platform can actually prove it — a running
PCM capture stream on Linux, a live IOAudioEngine on macOS. When it cannot be
determined the answer is unknown, never a guess: macOS exposes no dependable
camera-in-use signal, so strobe says so rather than inventing a green light.
The TUI puts the same data behind panel 0 and keeps a ● mic ○ cam indicator
in its header; the dashboard gives it a panel with a tally light.
One HTML file, no build step, no CDN. Gauges, a per-core heat strip, scrolling history graphs and the process table, all driven by the same JSON the CLI prints, streamed over Server-Sent Events.
git clone https://github.com/JeffPHenry/strobe.git
cd strobe
./strobeThat is the whole install. To run it from anywhere, link it onto your PATH:
ln -s "$PWD/strobe" /usr/local/bin/strobe # already on PATH on most systems
# or, if that is not writable:
mkdir -p ~/.local/bin && ln -s "$PWD/strobe" ~/.local/bin/strobeThen strobe, strobe --tui, strobe --serve work from any directory.
strobe resolves its own symlink, so it finds lib/, server/ and web/
wherever you link it from — which also means git pull updates the command,
with nothing to reinstall. Remove it with rm /usr/local/bin/strobe.
macOS. Apple ships bash 3.2; strobe needs 4+. brew install bash and you
are done: strobe notices the old shell and re-executes itself under
/opt/homebrew/bin/bash or /usr/local/bin/bash automatically. python3 (the
Xcode command-line tools' copy is fine) gives you per-core CPU numbers and the
web backend; without it you still get aggregate CPU and everything else.
Debian / Ubuntu. Nothing to install for the core sections. Optional packages light up optional sections:
| Package | Section it enables |
|---|---|
iproute2 (ss) |
listening ports, with owning process when run as root |
systemd (systemctl) |
failed units |
docker.io / docker-ce |
running containers |
nvidia-driver-* (nvidia-smi) |
GPU utilisation, VRAM, temperature, power |
bluez (bluetoothctl) |
connected Bluetooth peripherals |
alsa-utils (amixer) or pulseaudio-utils (pactl) |
microphone mute state |
python3 |
--serve backend |
mailutils / bsd-mailx (mail) |
--email delivery |
jq |
not needed by strobe, but you will want it for --json |
Every optional section degrades to a one-line "skipping" note when its tool is missing. Nothing dies.
Usage: strobe [MODE] [OPTIONS]
Modes (default: text report):
--json Print one JSON sample and exit.
--follow With --json: print one JSON object per line, forever.
--tui Interactive terminal UI.
--serve [PORT] Start the web dashboard backend (default port 8787).
Options:
--output FILE Report mode: also write the report to FILE.
--email ADDR Report mode: also mail the report to ADDR (needs 'mail').
--interval N Sampling interval in seconds for follow/tui/serve (default 1).
--top N Number of processes to collect (default 20).
--demo Use synthetic, animated data (works anywhere).
--no-color Disable ANSI colour.
--version Print the version and exit.
--help, -h Show this message and exit.
--follow on its own implies --json. --interval accepts fractions
(--interval 0.5). The report always samples over one interval so rates
(CPU %, disk and network B/s) are real, not zero.
All of these are read by lib/core.bash and can be set for any mode.
| Variable | Default | What it does |
|---|---|---|
STROBE_PROC |
/proc |
root of procfs; point it at a fixture tree to replay a machine |
STROBE_SYS |
/sys |
root of sysfs |
STROBE_ETC |
/etc |
where os-release and passwd are read from |
STROBE_DEMO |
unset | 1 forces synthetic data (same as --demo) |
STROBE_INTERVAL |
1 |
sampling interval in seconds (--interval) |
STROBE_TOP_N |
20 |
processes to collect (--top) |
STROBE_VPN_IFACE |
tun0 |
interface whose presence and address means "VPN up" |
STROBE_SLOW_EVERY |
5 |
ticks between refreshes of the expensive collectors (ss, systemctl, docker, nvidia-smi, logins) |
STROBE_PORT |
8787 |
backend port (--serve PORT) |
STROBE_BIND |
127.0.0.1 |
backend bind address; see the security note before changing it |
NO_COLOR |
unset | any value disables colour, per no-color.org |
STROBE_FORCE_COLOR |
unset | 1 keeps colour on when stdout is not a tty |
STROBE_ASCII |
unset | 1 forces the pure-ASCII glyph set in the report (automatic outside UTF-8 locales) |
STROBE_REPORT_WIDTH |
auto | report width: a number (80–220), auto, or fixed. Unset fills the terminal when you are reading the report and pins 100 when it is piped or redirected, so files keep diffing. Same knob as --width |
STROBE_ALLOW_SUDO |
unset | macOS only: 1 allows one passwordless sudo -n powermetrics per slow tick to read the CPU die temperature |
STROBE_TUI_FRAMES, STROBE_TUI_COLS, STROBE_TUI_LINES, STROBE_TUI_KEYS |
unset | headless TUI: render N frames to stdout at a fixed size, feeding keys; used by the tests |
Defined once, at the top of lib/core.bash, and printed in the report footer
so a reader always knows which rules produced the verdict.
| Metric | Caution | Unhealthy | Applies to |
|---|---|---|---|
| cpu load | >= 1 (CPU_LOAD_CAUTION) |
>= 2 (CPU_LOAD_UNHEALTHY) |
load1 / logical cores |
| cpu busy | >= 80% (CPU_PCT_CAUTION) |
>= 95% (CPU_PCT_UNHEALTHY) |
user + sys + iowait + steal |
| memory | >= 85% (MEM_PCT_CAUTION) |
>= 95% (MEM_PCT_UNHEALTHY) |
used = total - available |
| swap | >= 50% (SWAP_PCT_CAUTION) |
- | swap used |
| disk | >= 90% (DISK_USAGE_CAUTION) |
>= 95% (DISK_USAGE_UNHEALTHY) |
space and inodes, per mount |
| temperature | >= 75°C (TEMP_CAUTION) |
>= 90°C (TEMP_UNHEALTHY) |
CPU package and GPU |
| services | any failed unit | - | systemctl --failed |
| vpn | tun0 down |
- | VPN interface has no address |
Overall health is the worst check. The report's last line is a one-line
SUMMARY host=… load1=… cpu=… mem=… disk=… status=… that is easy to grep out of
a log directory.
| Key | Action |
|---|---|
q, Esc, Ctrl-C |
quit (restores the terminal) |
h, ? |
help overlay — reflows to the terminal, goes two-column when short, scrolls when even that overflows |
Tab / Shift-Tab |
cycle focus between panels; focus changes what the graphs plot (the selected interface, the selected mount, per-core instead of total) |
↑ ↓ j |
move selection in the focused list (processes, disks, interfaces) |
PgUp PgDn Home End |
page / jump in the process list |
s / S |
cycle process sort forward / backward: cpu → mem → pid → name |
/ |
filter (substring on command/args/user/pid); Esc clears |
e |
details of the selected process in a footer box: full args, user, state, threads, cpu, mem, rss |
k / K |
kill the selected process, with confirmation — TERM, or KILL with K |
T |
grouping view: consecutive rows with the same command collapse to one ×N row with summed cpu/mem/rss; → ← expand and collapse |
i |
swap the footer key hints for a one-line explanation of the focused panel's numbers |
1..9 |
toggle panels (1 CPU, 2 MEM, 3 DISK, 4 NET, 5 GPU, 6 TEMPS, 7 PROC, 8 SERVICES/PORTS, 9 HEALTH) |
+ / - |
interval up/down (0.5 s steps, 0.5 – 10) |
g |
cycle graph glyphs: block ▁▂▃▄▅▆▇█ → braille ⣀⣤⣶⣿ → dots |
c |
cycle theme: aurora → synthwave → ember → glacier → matrix → mono |
t |
cycle per-core view: bars grid → heat strip → one sparkline per core |
m |
mini mode for tiny tmux panes — as few as 7 rows, and it fills whatever it is given |
H |
horizontal layout: panels left, full-height process list right (needs ≥ 140 cols) |
r |
force the slow collectors to refresh now |
p |
pause / resume sampling |
w |
write a full text report to ./strobe-report-<timestamp>.txt |
Minimum terminal size is 80×24; truecolor when COLORTERM advertises it,
256-colour otherwise, and a mono theme for everything else.
./strobe --serve [PORT] runs server/strobe_server.py, which spawns
strobe --json --follow, keeps the latest sample plus a ring buffer of history
(900 samples by default), opens your browser, and serves:
| Route | Response |
|---|---|
GET / |
the dashboard (web/index.html) |
GET /api/stats |
latest sample (JSON, same schema as --json) |
GET /api/history?n=300 |
{"samples":[...]}, oldest → newest |
GET /api/stream |
text/event-stream; one data: <sample> event per tick, with replay of recent history on connect |
GET /api/report |
the plain-text report (text/plain) |
GET /api/meta |
{"version","hostname","interval","started","demo","mode","platform","history","samples","clients"} |
/api/* answers with Access-Control-Allow-Origin: * so other local tools can
read it. ./strobe --serve accepts --bind ADDR and --no-open (or
STROBE_BIND / STROBE_NO_OPEN=1); the backend can also be run directly for
its extra switches: python3 server/strobe_server.py --help (--history N,
--mock for a bash-free synthetic feed, --quiet, -v, --version).
curl -s localhost:8787/api/stats | jq .health
curl -sN localhost:8787/api/stream | head -c 2000--json prints one object; --json --follow prints one per line (NDJSON),
forever, until you stop reading. Keys are stable and documented in
docs/ARCHITECTURE.md; unknown values are null.
# per-core busy %, as a list
./strobe --json | jq '.cpu.per_core'
# the top process
./strobe --json | jq -r '.processes.list[0] | "\(.pid)\t\(.user)\t\(.cpu)%\t\(.cmd)"'
# one-word verdict, handy in scripts
./strobe --json | jq -r '.health.overall'
# disks over 80%
./strobe --json | jq -r '.disks[] | select(.pct > 80) | "\(.mount) \(.pct)%"'
# a live one-line-per-second feed of the numbers you care about
./strobe --json --follow | jq -c --unbuffered '{t: .ts, cpu: .cpu.total, mem: .memory.pct, rx: .net.rx_bps}'
# 60 seconds of samples to a file, then the average CPU
./strobe --json --follow | head -n 60 > samples.ndjson
jq -s 'map(.cpu.total) | add / length' samples.ndjsoncron, one line, one file per night:
0 2 * * * /home/you/.local/bin/strobe --no-color --output /var/log/strobe/$(date +\%F).txt >/dev/null 2>&1systemd, if you prefer journald to know about it (systemctl --user works the
same way for an unprivileged user):
# /etc/systemd/system/strobe-report.service
[Unit]
Description=strobe nightly system report
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'exec /usr/local/bin/strobe --no-color --output /var/log/strobe/$(date +%%F).txt'# /etc/systemd/system/strobe-report.timer
[Unit]
Description=Run strobe-report nightly
[Timer]
OnCalendar=02:00
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl enable --now strobe-report.timerAdd --email ADDR to either ExecStart or the cron line to have the same text
mailed through the local mail command.
strobe only needs bash on the far end, so a checkout in ~/.local/bin on each
box is the whole deployment.
ssh box 'strobe --json' | jq .health # structured verdict from a remote host
ssh box 'strobe --no-color' > box-report.txt # the full report, saved locally
for h in web1 web2 db1; do ssh "$h" 'strobe --json' | jq -r '"\(.system.hostname)\t\(.health.overall)"'; donestrobe (entry point / dispatcher)
│
├── lib/core.bash has(), thresholds, env knobs, formatting, JSON primitives, colour, platform
├── lib/collect.bash THE collector: fills bash globals from /proc + /sys (linux) or a synthetic model (demo)
│ ├── lib/collect_darwin.bash macOS overrides: sysctl, vm_stat, netstat, iostat, ps
│ └── lib/darwin_cpu.py per-core CPU ticks on macOS (host_processor_info via ctypes)
├── lib/report.bash long-form text report (default, --output, --email)
├── lib/tui.bash terminal UI (--tui)
├── server/strobe_server.py HTTP + SSE backend (--serve) ← spawns `strobe --json --follow`
└── web/index.html animated dashboard, served by the backend
One collector, one data contract, four renderers. The collector is sourced and
called in-process by the report and the TUI; the web side talks to it through
--json --follow. Rates come from deltas between two ticks, which is why every
mode takes one interval before it has something to show.
strobe dispatcher: bash-version gate, symlink resolution, arg parsing, mode dispatch
lib/core.bash thresholds and STROBE_* defaults live at the top of this file
lib/collect.bash _lin_* (procfs/sysfs), _demo_* (synthetic), collect_* dispatchers, collect_json
lib/collect_darwin.bash _dar_* collectors; re-declares collect_* to add a darwin branch
lib/report.bash _rep_* one function per section, report_main
lib/tui.bash _t_panel_* one function per panel, _t_render, _t_handle_key
server/strobe_server.py stdlib only: ThreadingHTTPServer, collector thread, SSE fan-out
web/index.html the dashboard, CSS + JS inline, no build
tests/run.bash lint + fixture collector + demo + CLI + server smoke (make test)
tests/test_report.bash tests/test_tui.bash tests/test_darwin.bash standalone suites
tests/fixtures/ proc/, proc2/ (two ticks of a fake machine), sys/, etc/
docs/ARCHITECTURE.md the data contract; docs/TUI.md the TUI spec
make test # tests/run.bash
make lint # bash -n + shellcheck on everything
make demo # ./strobe --demo
make tui # ./strobe --tui
make serve # ./strobe --serve 8787 (PORT=9000 make serve)
bash tests/test_report.bash; bash tests/test_tui.bash; bash tests/test_darwin.bashOn macOS run all of that with BASH=/usr/local/bin/bash make test (or
/opt/homebrew/bin/bash).
Fixture tests. The collector never touches the live system in tests:
STROBE_PROC=tests/fixtures/proc STROBE_SYS=tests/fixtures/sys STROBE_ETC=tests/fixtures/etc
makes it read a frozen machine, and swapping STROBE_PROC to proc2 between
collect_init and collect_tick gives deterministic deltas (core 2 at 100%,
core 3 at 0%, a known number of bytes through eth0). To reproduce a bug from
a real box, copy the relevant /proc and /sys files into a new fixture tree
and assert on the globals.
Adding a collector. Add _lin_foo (and _demo_foo, and _dar_foo if
macOS can supply it), a collect_foo dispatcher, and a line in _collect_run.
Declare the new globals in _collect_reset, emit them in collect_json, write
the contract into docs/ARCHITECTURE.md, and add fixture files plus assertions
to tests/run.bash. Then render: a _rep_foo section in lib/report.bash, a
_t_panel_foo in lib/tui.bash, a panel in web/index.html. Rules: gate
optional tools with has, never print from lib/collect*.bash, never exit,
and keep expensive work behind _SLOW.
Adding a TUI theme. A theme is a name in T_THEME_NAMES and five RGB stops
in T_THEME_STOPS in lib/tui.bash; borders, gradients and accents all derive
from the stops. Utilisation colours come from grad_pct_color in
lib/core.bash and are the same in every theme.
Data contract. docs/ARCHITECTURE.md is binding: globals, JSON keys and
HTTP routes. If you change the code, change the doc in the same commit.
It says "needs bash >= 4" on my Mac.
macOS ships bash 3.2 from 2007. brew install bash; strobe re-executes itself
under the Homebrew bash automatically, so ./strobe keeps working from the
system shell. You can also call it explicitly: /usr/local/bin/bash strobe.
Why is the web backend Python when everything else is Bash?
Because a correct HTTP/1.1 server with keep-alive, SSE fan-out and a ring buffer
is a bad idea in Bash and a 500-line stdlib script in Python. It uses only the
standard library, so any python3 works, and the collector it runs is still the
same bash script. If you do not want Python, --json --follow gives you the same
stream to pipe wherever you like.
Headless server, dashboard on my laptop? Leave the backend bound to localhost on the server and forward the port:
ssh -L 8787:127.0.0.1:8787 box 'strobe --serve'
# then open http://127.0.0.1:8787 locally--serve tries to open a browser; on a headless box that is a no-op.
Is it safe to expose the dashboard?
The backend binds to 127.0.0.1 by default and the API is read-only, but it
exposes hostnames, IPs, listening ports, logged-in users and process command
lines. STROBE_BIND=0.0.0.0 ./strobe --serve (or
./strobe --serve --bind 0.0.0.0) makes all of that readable by
anyone who can reach the port, with no authentication. The backend is a
stdlib ThreadingHTTPServer and is not hardened for the public internet:
keep it on a trusted network, behind SSH, or behind a reverse proxy that does
auth and TLS.
Does it need root?
No. Everything in /proc and /sys that strobe reads is world-readable.
Running as root only adds process names next to listening ports (ss -p) and
lets the TUI's k kill other users' processes.
What does demo mode actually do?
--demo swaps the collectors for an animated synthetic model (eight cores,
two disks, three interfaces, a GPU, a flaky bluetooth.service) so every mode
can be tried, screenshotted and tested on any OS without touching the host.
GPL-3.0. See LICENSE.

