terminal-status-panel
A small Python package that renders a colorful server status panel on login —
best run from a profile.d snippet so it uses the full terminal width (see
Running it at login). The full-width dashboard is laid
out in five tiers:
-
SYSTEM OVERVIEW (with a real, pre-rendered OS logo) beside UPDATES.
-
SYSTEM STATUS — load & per-core CPU usage, memory/swap, and a filesystem usage table.
-
DOCKER INFOS — Swarm key facts (summary + node health) above the node matrices: an Infrastructure and a Service table per origin — under SWARM STACKS and COMPOSE PROJECTS, each block omitted entirely when that origin has nothing — and one shared Standalone containers table below them for everything that belongs to no stack or project. Node health has three states: ✅ ready and active, ⚠️ ready but drained or paused (it accepts no tasks), 💀 unreachable; the summary line counts the non-operational ones, e.g.
5 nodes (1 drain, 1 down). Per-node replicas of the same service (e.g.kafka_kafka-<node>on every node) collapse into a single row; a stack with one logical service shows as one row named after the stack, a stack with several shows a header plus one sub-row per service (stack prefix and node name stripped). A trailing_<digits>is stripped the same way, so ordinal instances collapse too — a service deployed asconnector_1,_2,_3(one pinned instance per node, each with its own secrets) renders as a singleconnectorrow. That stripping is unconditional, and it has an accepted cost: if one instance is later removed from the deployment — or scaled to zero — its row just folds into the remaining two, and the panel reads✅ 2/2with nothing to say a third instance was ever expected. The gap is invisible only across a deployment change, never a failure — a failing instance still has a desired replica and renders💀or⚠️in both the Working column and its node cell, so a lost instance is silent but a broken one is not. Each row also carries a Working cell right after the service name: an icon plus the row's running/desired task count, e.g.✅ 3/3,⚠️ 2/5,💀 0/3,· 0/0. Four rules keep that cell from over-claiming:- a service scaled to zero replicas renders
· 0/0, not💀— that is a decision, not an outage; - for the four clustered services that actually run as Docker services
(PostgreSQL, MongoDB, Kafka, RustFS — GlusterFS is queried on the host via
sudo -n, not as a container, so it never gets a DOCKER INFOS row) the icon comes from that service's own CLUSTER HEALTH verdict while the count stays Docker's running/desired tally, so the two are allowed to disagree on purpose — except that a row measured💀or⚠️by its own replicas keeps that icon: no cluster-level✅or·can talk a row with nothing (or not everything) running into looking healthy; - under
status-docker, which runs without the health section, a clustered service's cell falls back to·plus Docker's own count instead of a replica-derived✅— "five brokers are running" is not the claim this column makes — and the same fallback applies when the probe found no member of that service on this node, or the kind is not listed inhealth.enabled. Withholding the cluster's claim does not withhold Docker's, so such a row still renders💀 0/3when nothing runs and nothing is still starting; - a service where at least one task is still starting (not yet in running
state) renders
⚠️in the Working cell's icon in place of💀, since a deploy in progress is not the same as a measured outage — a single starting task renders the bare icon⚠️, and a row with no running tasks but some still starting renders⚠️ 0/n. This differs from the node cell, which only shows⚠️ 0/Nwhen all tasks on that node are starting; a node cell with a mix of starting and failed tasks still shows💀 0/N.
A global-mode service (Traefik here) reports no replica count, so the denominator is the number of tasks Swarm actually scheduled — the same one
docker service lsshows. Counting against every node instead would render a healthy global service as degraded for as long as one node is drained or a placement constraint applies.RustFS is the worked example for the second rule: its own CLUSTER HEALTH block can read
3/5 live(a minority of members measured unhealthy while the majority quorum still holds) even though every RustFS container is still up as a Docker task, so the row here reads⚠️ 5/5— a bare replica count with no cluster context would have shown a clean✅ 5/5and missed the degradation entirely. A member that was simply never measured (healthy is None— MongoDB reports its replica-set members but not their state) does not raise this warning; not measured is not a failure.Columns are otherwise the nodes (alphabetical), plus a description column. A node cell holding a single task keeps the bare glyph, ✅, ⚠️, or 💀 — the overwhelming majority of cells, since most rows place one task per node — and from two tasks up it carries the count right after the glyph, with no space in between:
✅Nwhen all run,⚠️X/Nwhen some run,⚠️0/Nwhen none run and all are in a starting state,💀0/Nwhen none run and at least one has failed or entered another non-starting state. (Grouping can put more than one task on the same node — after ordinal stripping merges two originally separate services into one row, both can land on the same node, and that node's cell counts both.) See Icon vocabulary for what each glyph means — the Working column uses exactly the same vocabulary, no new glyphs, including✗when the underlying cluster probe itself failed (only…, the time-budget marker, never appears in this column). - a service scaled to zero replicas renders
-
CLUSTER HEALTH — the clustered infrastructure services this node participates in (PostgreSQL, MongoDB, Kafka, GlusterFS, RustFS) with leader/member state, WireGuard peer handshake ages (TCP-probe fallback when passwordless sudo is unavailable), and DNS consistency checks. Every check runs concurrently under a shared time budget (default 5 s); a check that runs out renders
…, deliberately distinct from✗for a check that actually failed. Each applicable service gets its own block (leader, members, warnings), and the blocks flow into as many columns as the terminal width allows — one column on a narrow terminal; services with no member on this node are not given a block each but summarised together on a single dimn/a here: <names>line. A service that should run here but has no running container (most likely a crash loop) still gets its own block, reading✗ <service>: no running containerinstead. See Cluster health checks below for the full icon vocabulary and what the section deliberately cannot see. -
TRAEFIK WIRING — Traefik's wiring as configured: one branch per entrypoint, each with its routers, their middlewares, and the service they point at. The branches are packed into as many height-balanced columns as the terminal allows, in the order the Traefik service's own arguments declare them — the four every cluster has (
dashboard,ping,default,https) before this cluster's per-vhost ones. Routers whose entrypoint does not exist get their own block below, at full width, since a tree keyed by entrypoint would otherwise drop them silently. See Traefik wiring below for what "as configured" means and for the optional (currently dormant) live cross-check against Traefik itself.
The panel itself opens no database or broker connection and holds no
credentials. Its only privilege is the Docker socket: the Docker section
reads the Swarm API, and the health section additionally executes
read-only status commands inside the service containers
(pg_autoctl show state, db.hello(), kafka-metadata-quorum.sh, a
/health curl). GlusterFS is queried on the host via sudo -n and is
skipped when that is unavailable.
Requirements
- Python 3.11+
- Linux (Debian/Ubuntu) in production; macOS supported for development.
- Optional: a running Docker daemon for the services panel.
Installation
pip install terminal-status-panel # from PyPI, once published
# or, from a checkout:
pip install .
This installs five panel commands (status-full, status-server,
status-docker, status-health, status-traefik) plus an install-panel
helper to wire it into a login shell.
Commands (sections)
The dashboard is split into four independently runnable sections, each with its own entry point — plus the combined command:
| Command | Sections | Use |
|---|---|---|
status-full |
server + docker + health + traefik | The full panel (default). |
status-server |
server only | System overview, updates, load/mem/fs. |
status-docker |
docker only | The Docker Swarm block. Collects no health, so a clustered service's Working cell falls back to Docker's own measurement — · only when Docker itself has nothing stronger to say (fully staffed or scaled to zero), still 💀/⚠️ for a row Docker measured dead or degraded — pair it with status-health to get the cluster verdicts. |
status-health |
health only | Clustered infrastructure services, WireGuard peers, DNS. |
status-traefik |
traefik only | Traefik's entrypoint → router → middleware → service wiring, as configured — the same block status-full shows, without the rest of the panel. |
Each section only collects the data it needs: status-docker never touches
the system collectors, status-server never opens the Docker socket, and
status-health never touches the system collectors either (though it does
open the Docker socket, to exec into service containers) — so you can run
just what a given host cares about. status-traefik also opens the Docker
socket, to list Swarm services and configs and to read the service states its
tree renders verdicts from (the DOCKER INFOS block itself stays unrendered),
but runs no exec and reaches no network beyond Docker unless the optional
[traefik] cross-check is configured (see
Traefik wiring). The combined command also
accepts --sections with any comma-separated subset to pick explicitly, e.g.
--sections docker,traefik for the two Docker-facing blocks alone.
The wiring block is the same either way — one rendering, packed into
height-balanced columns — so nothing is visible in status-traefik that a
login does not also show.
Any of the five works in the profile.d snippet (see Running it at login) —
e.g. call status-docker or status-health on Docker Swarm nodes,
status-server on plain servers, and status-traefik wherever you want to
check what Traefik is actually wired to serve.
Usage
status-full [--sections server,docker,health,traefik] [--width N] [--no-color] [--config PATH]
status-server [--width N] [--no-color] [--config PATH]
status-docker [--width N] [--no-color] [--config PATH]
status-health [--width N] [--no-color] [--config PATH]
status-traefik [--width N] [--no-color] [--config PATH]
The command always exits 0 so it can never break a login shell. If a collector fails (no Docker socket, non-Debian host, …) that section degrades to a placeholder instead of erroring.
Command-line options
| Option | Default | Description |
|---|---|---|
--sections |
(per command) | Comma-separated sections to render: server, docker, health, traefik. On status-full the default is all four; the dedicated commands fix their own section. The wiring block renders identically however it is selected. |
--width N |
(auto) | Force the render width to N columns. Overrides both auto-detection and the config width. |
--no-color |
off | Disable ANSI colours (plain text — useful for piping/debugging). |
--config PATH |
(see below) | Load configuration from PATH instead of the default location. A missing file is not an error (defaults are used). |
Colours are always forced on (unless --no-color), because at MOTD
generation time there is no TTY to auto-detect a colour terminal.
How the render width is chosen
The width is resolved in this order (first match wins):
--width N— an explicit flag always wins.- The current terminal width — used automatically when standard output is a real terminal (TTY), i.e. when you run the command interactively or from a shell-login hook. This is what gives you the full screen width.
widthfrom the config (default 80) — the fallback when there is no TTY, e.g. whenupdate-motd.dpre-generates the cached MOTD.
The panel is designed for wide terminals. Narrow widths still render but wrap.
Configuration
Zero configuration is required. Settings are read from
/etc/terminal-status-panel/config.toml (override with --config PATH). A missing
or unreadable file falls back to the built-in defaults — it never raises.
Configuration reference
| Key | Default | Meaning |
|---|---|---|
width |
80 |
Fallback render width when no TTY is available (see width resolution above). |
docker.timeout |
1.5 |
Seconds to wait for the Docker socket before giving up (also bounds the apt update check). Keeps a hung/absent daemon from delaying login. |
docker.description_label |
"status.description" |
Docker service label read as the per-service description column. The key lmu.service.description is still read as a fallback. |
resources.ignore_mountpoints |
platform-dependent | Mountpoint prefixes hidden from the filesystem table. Defaults to ["/System/Volumes/", "/Library/Developer/CoreSimulator/"] on macOS and to [] elsewhere. An explicitly empty list hides nothing rather than falling back to the default. |
docker.infrastructure_stacks |
["postgresql", "postgres", "kafka", "mongodb", "rustfs", "portainer", "traefik", "registry", "minio", "redis", "valkey", "mariadb", "mysql", "elasticsearch", "bugsink"] |
Case-insensitive substrings. A stack (or Compose project) whose name matches goes into that origin's Infrastructure table; every other stack goes into Service. An entry with no stack at all is never classified this way — it has no project to be filed under, so docker run -d redis lands in Standalone containers like any other stackless entry, however infrastructural its name. |
docker.infra_ui_services |
["kafbat-ui", "kafka-ui", "kafdrop", "cloudbeaver", "pgadmin", "adminer", "mongo-express", "mongo-gui", "rustfs-console", "rustfs-ui", "s3-browser", "s3browser", "redisinsight", "redis-commander", "portainer", "dozzle", "kibana"] |
Case-insensitive substrings matched against the stack name and the service name. Matching services leave their own stack and are collected as sub-rows of the pseudo stack infra-uis, shown first in the Infrastructure block. On a name matching both lists, this one wins. A sidecar pulled in only because its stack name matched (e.g. portainer_agent) is labelled stack/service so it stays attributable once detached. |
services.critical |
[] |
Service names flagged as critical (parsed and available on the data model; not visually emphasised in the current matrix view). |
thresholds.memory.warning / .critical |
75 / 90 |
RAM usage % thresholds (yellow / red). |
thresholds.swap.warning |
platform-dependent | Swap usage % above which SWAP turns yellow. Defaults to 80 on macOS, which allocates swap continuously by design, and to 1 elsewhere. An explicit value overrides both. |
thresholds.filesystem.warning / .critical |
80 / 90 |
Filesystem usage % thresholds. |
thresholds.load.warning / .critical |
0.8 / 1.0 |
Load-average thresholds as a per-CPU multiplier (compared against load1 / cpu_count). |
health.budget |
5.0 |
Total wall-clock budget in seconds for all health checks. Every check runs concurrently as its own task, so this bounds the login delay — it is not the sum of the individual timeouts. |
health.timeout.* |
postgres 1.5, mongodb 2.5, kafka 4.0, glusterfs 1.0, rustfs 2.0, wireguard 1.0, dns 2.5 |
Deadline for one check. Each cluster kind, the peer check and the DNS check are separate tasks; a task that overruns its value is reported as … <name>: time budget exceeded while every other check keeps its result. Values above health.budget have no effect — the budget always wins. See How the timeouts are enforced. |
health.enabled |
all five kinds | Which cluster kinds to probe: postgres, mongodb, kafka, glusterfs, rustfs. |
health.dns.expect |
[] |
Array of {name, addresses}. addresses is optional; without it the name only has to resolve at all. |
traefik.url |
(unset) | URL of Traefik's /api/rawdata endpoint for the optional live cross-check. Leave unset — see Traefik wiring for why it cannot work on today's app servers. |
traefik.cert / traefik.key |
(unset) | Client certificate/key for that endpoint (mTLS). Both url and cert must be set for the cross-check to run at all. |
traefik.ca |
(unset) | CA bundle to verify the endpoint's server certificate. Unset, httpx falls back to certifi's bundle, not the system trust store — a corporate CA installed in /etc/ssl/certs is not picked up (only SSL_CERT_FILE/SSL_CERT_DIR in the environment override it). Set this explicitly for a privately signed endpoint. |
Full example
# Fallback width for non-TTY (MOTD) rendering. Interactive logins auto-detect
# the real terminal width regardless of this value.
width = 200
[docker]
timeout = 1.5
description_label = "status.description"
infrastructure_stacks = ["postgresql", "kafka", "mongodb", "rustfs", "portainer", "traefik", "registry"]
infra_ui_services = ["kafbat-ui", "cloudbeaver", "mongo-express", "rustfs-console"]
[services]
critical = ["postgres", "kafka"]
[thresholds.memory]
warning = 75
critical = 90
[thresholds.swap]
warning = 1
[thresholds.filesystem]
warning = 80
critical = 90
[thresholds.load]
warning = 0.8 # per-CPU multiplier
critical = 1.0
[health]
budget = 5.0
enabled = ["postgres", "mongodb", "kafka", "glusterfs", "rustfs"]
[health.timeout]
postgres = 1.5
mongodb = 2.5
kafka = 4.0
glusterfs = 1.0
rustfs = 2.0
wireguard = 1.0
dns = 2.5
[[health.dns.expect]]
name = "login.example.net"
addresses = ["10.9.9.9"]
Platform behaviour
The panel is written for Linux servers but also runs correctly on a developer's Mac, on FreeBSD, and on the RHEL and SUSE families. This section is where those differences are collected.
- Identity. macOS reads
ProductNameandProductVersionfrom/System/Library/CoreServices/SystemVersion.plist, the file both macOS itself andplatform.mac_ver()read; every other system usesdistro, which covers Debian, Ubuntu, the RHEL family, the SUSE family and FreeBSD. There is deliberately no fallback chain — a system that cannot be identified reportsn/a (OS identity unavailable)rather than inventing a coarser answer. The kernel row always names its system as well as its release, e.g.Darwin 25.5.0orLinux 6.1.0-18-amd64, because the release number alone is ambiguous between platforms. - Filesystems on macOS.
/and/System/Volumes/Dataare two mounts of one APFS container; the panel reports the data volume's totals under/and drops the duplicate data-volume row. Without this merge,/reads as a reassuring 26 % used on a machine that is in fact 98 % full. Theresources.ignore_mountpointsconfig key (see above) then hides the remaining system volumes and simulator runtimes that would otherwise outnumber the real filesystems roughly seven to one. - Swap on macOS. The swap warning threshold defaults to 80 % there, rather than the 1 % used everywhere else, because macOS allocates swap continuously as a matter of design — the Linux default would warn on every healthy Mac.
- Logos. The logo is chosen by platform first, distribution second: a Mac
is a Mac whatever string
distroproduces, so platform identity always wins when it applies. Where no platform claims the system, the panel falls back to matching the distribution name, and finally to Tux — a true statement about the kernel for any Linux distribution without its own bundled mark — the RHEL and SUSE families are exactly that case, since their marks could not be licensed for redistribution. Systems Tux would misdescribe never borrow it: macOS gets its product name rendered as block lettering rather than an Apple emblem, since no Apple artwork is redistributed, OpenBSD and NetBSD get the BSD daemon, and FreeBSD shows no logo at all, its own wordmark being illegible at this size. Every bundled mark's provenance and licence are recorded inassets/logos/SOURCES.md. - Containers. Plain
docker runcontainers and Docker Compose projects now appear alongside Swarm services, not just services from an active Swarm. They are grouped by their Compose project into their ownCOMPOSE PROJECTSblock (mirroring theSWARM STACKSblock above it); a container with no Compose project lands underStandalone containers, as does a Swarm service created outside any stack — both are standalone, and neither is given a project heading it does not belong to. A container that exited cleanly (exit code0) is treated as finished work and is omitted, in both blocks. Beyond a clean exit, the two kinds are not treated alike: a Compose container that exits with a non-zero code stays visible and shows as a shortfall against its group, the same way a stopped Swarm task would; a standalone container has no group to fall short against, so it is shown only while it isrunningorrestarting— once it exits, at any exit code, it disappears rather than lingering. Without that difference, every one-offdocker runleft behind on a development machine would accumulate in the panel forever.
Cluster health checks
status-health (and the health block inside status-full) probes the
clustered infrastructure services this node participates in, plus WireGuard
peer reachability and DNS consistency. Every service probe runs a read-only
status command inside a locally running container via the Docker API —
GlusterFS is the one exception, queried on the host via sudo -n — so none of
this needs database or broker credentials:
| Service | Command |
|---|---|
| PostgreSQL | pg_autoctl show state |
| MongoDB | db.hello() via mongosh (unauthenticated) |
| Kafka (KRaft) | kafka-metadata-quorum.sh describe --status |
| GlusterFS | gluster peer status --xml / gluster volume status --xml via sudo -n |
| RustFS | curl against /health on each configured endpoint |
WireGuard peer reachability is read from sudo -n wg show all dump
(handshake age per peer); when that is unavailable it falls back to a plain
TCP probe of the Swarm port (2377) per peer, and the PEERS sub-header shows
which method was used (wg, tcp, or mixed when peers disagree).
The five kinds share one listing of the local containers, fetched once per
run without inspecting each container. Asking per probe cost roughly
kinds × (1 + containers) Docker round trips at every login — around 205 on a
host with 40 containers — and the per-container inspect could raise a 404
for a container that stopped mid-listing, which the panel would have shown as
a failed check. Only RustFS needs a container's full attributes (for
RUSTFS_VOLUMES), and it inspects only the one container it matched.
Measured on a production manager of a five-node Swarm: the whole section
takes 3.25 s end to end, within the 5 s default budget, with individual
probe costs of PostgreSQL pg_autoctl show state 0.13 s, Kafka
kafka-metadata-quorum.sh 2.6 s (JVM startup, not optimisable), GlusterFS
gluster … --xml 0.10 s, and RustFS /health ~0.2 s. That cluster runs no
MongoDB, so its figure — db.hello() 0.95 s — was measured separately on
another cluster.
How the timeouts are enforced
Every check is its own task: each enabled cluster kind, the peer check and
the DNS check. They all start together and are waited for concurrently, each
until the earlier of its own health.timeout.<name> and the shared
health.budget. A check that misses its deadline is reported as
… <name>: time budget exceeded for that check alone — the kinds beside
it keep their results, so a hung RustFS can no longer hide a PostgreSQL
quorum loss that was measured two seconds earlier.
The deadline is what the panel waits for; whether the check itself stops work at that moment depends on what it runs:
- GlusterFS and RustFS pass their value down to the child process
they spawn (
subprocesstimeout,curl -m), so the work really stops. RustFS divides its value between its endpoints, so five endpoints behind a blackhole cost the RustFS timeout once, not five times. (Below ~0.1 s per endpoint — more than 20 endpoints at the default — the share stops shrinking and the task deadline takes over instead.) - PostgreSQL, MongoDB and Kafka run through
docker exec, and docker-py bounds an exec by the client's socket timeout rather than per call. Their timeout is therefore enforced as the task deadline: the panel stops waiting and reports the check as out of budget, while the exec itself finishes in a daemon thread whose result is discarded. So that the socket timeout never expires first — which is what made the defaultdocker.timeout = 1.5unable to accommodate the ~2.6 s Kafka probe at all — the health section's Docker client is built with a socket timeout no smaller than the largest enabledhealth.timeout.*.docker.timeoutcontinues to bound the DOCKER INFOS section exactly as before.
An abandoned check keeps running in a daemon thread, and one that is inside a
child process (sudo -n wg, sudo -n gluster) when the panel exits would
leave that child orphaned. Such a thread is therefore given a short, bounded
grace period (0.25 s) at exit to finish or to kill its own child — bounded
because the delay would otherwise be paid by the login shell, and only ever
paid when a check has already been abandoned.
The own-hostname lookup the DNS check needs (socket.getfqdn(), a forward
plus a reverse lookup through NSS) happens inside the DNS task for the same
reason: with a broken resolver it blocks for tens of seconds, and that is the
very fault this section reports on. The Swarm node list the peer and DNS
checks need is fetched inside the budget too, once, by whichever of the two
gets there first — and only when the Docker section did not already collect
it for free.
Exactly one thing leaves the process before the budget starts: constructing
the Docker client, which negotiates the API version with the daemon. That
request is bounded by docker.timeout (1.5 s by default), the knob documented
for it; the larger health timeout is applied to the client afterwards, so it
bounds only the requests made inside the budget.
Icon vocabulary
The section's core idea: it never claims to know something it did not measure.
| Icon | Meaning |
|---|---|
| ✅ | measured healthy |
| ⚠️ | warning (e.g. a lagging PostgreSQL replica, a diverging DNS entry) |
| 💀 | measured broken |
· |
not observable / not attempted — see below for where it appears |
… |
the check ran out of the shared time budget |
✗ |
the check itself failed (a command errored, a connection was refused) |
n/a here |
not applicable — this node runs no member of the service |
… and ✗ mean different things and must not be conflated: a budget
timeout says nothing about the service's health, only that the panel gave up
waiting for it; a failed check (✗) is a statement about the service, or
about the tool used to ask it.
The neutral dot (·) exists because MongoDB reports its replica-set
members but not their state: db.hello() tells the panel who belongs to
the set, but only the primary and the member it just ran the command against
have known state — every other member is genuinely unmeasured, and rendering
an unearned ✅ for it would be worse than saying nothing. Every other check in
this section (PostgreSQL's pg_autoctl rows, Kafka's quorum voters,
GlusterFS peers/bricks) reports ✅ or 💀 for each member it lists, never ·,
because those commands do report per-member state. A service whose own
quorum was never established shows no icon at all next to its name, just a
dim "quorum not reported" note — so "not observable" (the dot) and "never
asked" never look the same. That note appears whenever the panel has no basis
for a quorum verdict:
- the probe errored before parsing anything;
- the command succeeded but its output was not recognisable (an empty
pg_autoctltable, a Kafka status in an unexpected format after an upgrade, a GlusterFS answer with neither peers nor volumes) — an unrecognised answer is not a measurement, and rendering 💀 for one would raise a red alarm for a perfectly healthy cluster; gluster peer statusreports zero other peers, i.e. a single-node volume, which peer status alone cannot establish a quorum for;- RustFS's
RUSTFS_VOLUMEScould not be read, so the endpoint list is a guess — the line then also reads(endpoint list unknown)next to the live count, because "1/1 live" against a guessed endpoint says nothing about a five-node cluster.
All three blocks can additionally read not checked (…) instead of a false
clean result: the CLUSTERS block shows not checked (no Docker client) when
there is no Docker socket to probe from (or every cluster kind is disabled in
config), the PEERS block shows not checked (no peer list available) when
the check produced no result and there were no peer names to check in
the first place — i.e. neither the wg/TCP probe answered nor a Swarm node
list gave it anything to ask about — and the DNS block shows not checked (DNS check did not run) when the section has no collected data at all (the
DNS check itself always runs, so within a real run this line cannot appear;
it guards the case where the whole section is missing). All exist for
the same reason — an empty list from a check that never ran would otherwise
look identical to "checked, found nothing," which is a false clean bill of
health. Those lines are also prefixed with the same neutral dot (·) as the
MongoDB member case above: a block that never ran carries exactly that claim
— not observed, nothing more said.
Where a probe deliberately narrows what it looks at, it says so in the same
place: (+N more volumes) when a GlusterFS host serves more than the one
volume reported, and (+N more containers) when a node runs more than one
container of the same kind (a pg16 → pg18 migration, say) and only the
first was probed.
A service with no running container
n/a here (this node legitimately runs no member of the service — no MongoDB
on a node that only hosts PostgreSQL, say) is not the same as a service that
should be running here but is not. When the Swarm service spec pins a
service to this node's hostname (or the service runs in global mode) with at
least one desired replica, but no matching container is currently running —
most likely a crash loop — the panel reports ✗ <service>: no running container (the service title followed by the error) instead of n/a here.
This check is deliberately narrow: an unpinned replicated service (no
node.hostname placement constraint) is never flagged this way, even while
it is crash-looping, because it could legitimately be scheduled on a
different node. The DOCKER INFOS block, which reads the live Swarm service
list rather than the placement spec, still shows such a service correctly —
this blind spot is specific to the health section's per-node view.
What the section cannot see
- MongoDB quorum means only "a primary exists".
db.hello()reports membership and who the primary is, but not per-member replication state; that would needrs.status(), which needs credentials this panel deliberately does not hold. - RustFS heal and erasure-coding state are invisible:
/healthis a liveness check only, and the admin API that reports heal/EC state answers 403 without credentials the panel does not have. - An unpinned crash-looping service is not flagged (see above) — DOCKER INFOS still shows it, just not the health section.
- GlusterFS needs passwordless
sudo -n gluster …on the host; without it (no sudo rule, noglusterbinary), the block reportsn/a hererather than an error — the tool being unreachable says nothing about the volume's actual health.
Traefik wiring
status-traefik (and the traefik section inside status-full) reads
Traefik's entrypoint → router → middleware → service wiring straight from the
Docker API: the entrypoints from the Traefik service's own command arguments,
the routers/middlewares/services from every Swarm service's traefik.http.*
labels, and the file-provider routers from the mounted Docker configs
(traefik_dynamic*) — the api and ping-router entries live only there. No
credentials beyond the Docker socket are needed, and no change to the Traefik
deployment.
Only the config generations the Traefik service actually mounts are read.
Swarm keeps every generation of a config — traefik_dynamic_yml_v1 through
_v4 may all still exist — but only the ones named in the service spec are
the ones Traefik has loaded. Selecting by name instead showed ping-router
four times on every entrypoint and turned entrypoints that were removed two
revisions ago into orphaned-router findings. Where the Traefik service cannot
be found at all, no generation is guessed: the file-provider routers are
reported as missing, with the reason named, rather than shown as if they were
current.
The panel renders one branch per entrypoint (sorted by port), each listing
its routers (dimmed when they come from the file provider), their
middlewares, and the Docker service each one points at — cross-checked
against the same Swarm service data the DOCKER INFOS section uses, through
the same service_verdict, so one service never gets two verdicts. That
data is collected whenever the traefik section runs, including for a bare
status-traefik; the DOCKER INFOS block itself is not rendered as a side
effect. When the Docker daemon gives no answer at all — no client, or an
unreachable or non-Swarm daemon — the service line shows a neutral ·
rather than claiming the service is missing, since nothing was measured. A
router naming no
entrypoint is attached to every entrypoint by Traefik itself, so it appears
under all of them; an entrypoint with no attached router reads — no router, which is a finding (a published port nothing serves), not an
absence.
Layout and order
The entrypoint branches are packed into as many columns as the terminal
allows, balanced by height rather than filled row by row: rich.Columns,
which CLUSTER HEALTH still uses, fills a grid row by row, so a row is as tall
as its tallest cell and a three-line branch beside a twenty-line one leaves
seventeen blank lines behind it. The packer used here fills column by column
instead, putting the tallest branches each in a column of their own and
stacking the short ones together, so the whole layout is only as tall as its
fullest column. There is no fixed "three columns at 190, one at 60" to name,
because the column count now falls out of which branches land in which
column on the actual terminal width, not out of a uniform column width the
way Columns computed it. The orphaned-router block stays full width below
the columns: its lines are the longest in the section, and it is what you
read first. Each entrypoint's head line carries the worst verdict among its
routers, so a wall of branches still says at a glance which one to open.
Entrypoints appear in the order the Traefik service's arguments declare
them, not by port. The Ansible role lists the four every cluster has —
dashboard, ping, default, https — before appending this cluster's
per-vhost ones, and that grouping is more useful than the numeric order, which
would put https (443) first and dashboard (8082) last and scatter the four.
Folding endpoints that claim nothing
A router pointing at one of Traefik's own @internal endpoints — the ping
router that answers /ping is the everyday example — has nothing to report
on its service line: nothing about @internal was ever measured, so there is
no verdict to show, only the target's name. Rather than spend a whole row on
that name, it is folded onto the router's own line instead:
└─ ping-router Path(`/_traefik_ping_`) → ping@internal
Nothing is hidden and no verdict is dropped, because there was no verdict to
drop in the first place; a router carrying a middleware, or pointing at a
real service, keeps its service line on its own row exactly as before. The
fold does cost the branch some width, though, and a wider branch can push a
column over the terminal's width and cost the whole section a column back —
paying several lines on screen to save one. So the panel builds both the
folded and the unfolded form of every branch, packs each independently, and
draws whichever one actually packs to fewer lines. On a shape of six
entrypoints that share nothing but one ping router, folding unconditionally
would cost a column at a terminal width of 120 (14 lines instead of 11);
packing both and choosing avoids that.
Entrypoints that are supposed to look empty
--ping.entryPoint=ping makes Traefik answer /ping on that entrypoint
itself, with no router involved. It is read from the same arguments, and that
entrypoint reads — Traefik's own health check instead of — no router, so
the one port that is meant to carry nothing does not read as a finding.
Every other empty entrypoint still does — an internal https :443 with
nothing routed to it genuinely has nothing behind it.
Services the file provider declares
A router can point at a service defined in the dynamic YAML rather than in
Swarm — account-api → account-api-placeholder →
http://user-account.internal is the live example. Those services are read
along with the routers, and the upstream URL is shown in place of a Docker
verdict, with a ·: nothing about that target was measured. Matching them
against Swarm service names instead reported ✗ no such service for something
that was never supposed to be a Swarm service.
What "as configured" means, and its limit
Everything above is read from configuration — labels and YAML — never from
Traefik's own runtime state. A router with a typo'd rule, or naming an
entrypoint that does not exist, still appears here exactly as declared,
because nothing in this reading path asks Traefik whether it actually
accepted it. The real case on this cluster: the image_api router's label
names the entrypoint websecure (Traefik's own common naming convention for
a TLS entrypoint), but that cluster's nine entrypoints are named dashboard,
ping, default, https, login_example_net, portalmgmt,
www_example_net, db-ui and kafbat — no websecure among them, so the
router is wired to a port that plainly doesn't exist. Since a
tree keyed by entrypoint has no branch to put such a router under, it would
otherwise vanish from the panel silently. Instead it gets its own
ORPHANED ROUTERS block, listing the router, the entrypoint name(s) it
refers to that do not exist, its rule, and the service it would have pointed
at.
When the Docker configs backing the file provider could not be listed at
all, a file provider unreadable: … warning appears above the tree — a
partial-read failure, distinct from the routers simply being empty. Because
api and ping-router live only in the file provider, this warning is the
signal that their absence below is a read failure, not a finding.
The optional live cross-check
The [traefik] config section (url, cert, key, ca — see
Configuration reference) is meant to close the
"as configured" gap: given Traefik's /api/rawdata endpoint and a client
certificate, the collector asks Traefik what it actually accepted and
records the answer per router.
It is dormant on every app server today, and should stay unset. Reaching
that endpoint needs a client certificate signed by the webfe CA, and the
Ansible role that provisions app servers currently issues only
app-server TinyCA certificates — for Traefik→service mTLS, a different
trust chain than the one the dashboard's own listener expects. Configuring
traefik.url without a certificate the dashboard accepts does not error: an
unreachable or rejected connection is treated the same as "not configured"
(see fetch_accepted in collectors/traefik.py) and the check is silently
skipped, so no test will surface the mistake. Leave the section unset until
the app servers have a certificate from the right CA.
When the cross-check does run, the tree shows its answer: a router Traefik
reported as not enabled is marked 💀 rejected by Traefik on its own line
— the configuration is there, Traefik declined it. The accepted case adds
nothing: the tree already reads as configured-and-accepted, and a second
checkmark on every line would only be noise.
Nothing is marked unless Traefik was actually asked and actually answered
about that router. With [traefik] unset, unreachable, or answering in a
shape the parser cannot read (a router whose entry carries no status at
all), no marker appears — "we did not ask" and "Traefik said no" must never
look alike.
Running it at login
Recommended: run it from profile.d (the login shell), not from
update-motd.d. This is the setup we use in production, and the reasoning is
explained below. Use one method only — running both prints the panel twice.
Why profile.d and not update-motd.d?
update-motd.d looks like the natural home for a login banner, but it cannot
render at the viewer's terminal width:
pam_motdruns theupdate-motd.dscripts during PAM session setup, before the login shell starts. At that moment the script has no controlling TTY and the terminal size is not available;COLUMNS/LINESare only set later, by the interactive shell.- SSH does not change this. SSH knows the client's window size (from its
pty-req), but it does not pass it to the MOTD scripts. So whether you connect by SSH or by a VM console, the result is the same. - The output is also typically cached (
/run/motd.dynamic) and shown to every subsequent login regardless of their window — a single fixed rendering.
The net effect: update-motd.d always renders at a fixed width (the config
width, default 80). For our environment that is exactly wrong:
- We reach every server only over SSH — never a VM/VMware console — so a real terminal with a known size is always present at the shell, just not at MOTD-generation time.
- We work from MacBooks and 4K displays, where an 80-column banner is either cramped or wastes most of the screen. We want the panel to fill whatever window the login happens in.
A profile.d snippet runs inside the interactive login shell, where stdout
is the SSH pty and its (SSH-negotiated) size is available. The tool then
auto-detects and uses the full current terminal width on every login — wide
on a 4K display, snug in a small MacBook window — with no fixed value to
maintain. That flexibility is why we chose it.
Install with install-panel
The install-panel command writes the login snippet for you — system-wide or
per-user — and is idempotent (safe to re-run) and reversible.
# System-wide, all users (writes /etc/profile.d/zz-terminal-status-panel.sh):
sudo install-panel --scope global
# Per user, no root needed (managed block in ~/.profile or ~/.zprofile):
install-panel --scope user
# Pick which panel(s) to show — e.g. Docker + cluster health on a Swarm node:
sudo install-panel --scope global --panel docker --panel health
# …or any other combination as separate commands:
install-panel --scope user --panel server --panel docker
# Preview without writing, then remove again:
install-panel --scope user --dry-run
install-panel --scope user --uninstall
A Swarm node is the natural case for --panel docker --panel health
together: the Docker Swarm block and the clustered-services health block
answer different questions (what's scheduled vs. what's actually healthy)
and both only make sense where the Docker socket is available. Installed
alone, --panel docker collects no health at all, so the Working cell of
every clustered service falls back to Docker's own measurement — · only for
a row Docker itself measured clean (fully staffed or scaled to zero), still
💀/⚠️ when Docker measured it dead or degraded — honest, but the column
only earns its cluster icons with --panel health beside it.
Options:
| Option | Values | Default | Meaning |
|---|---|---|---|
--scope |
global | user |
user |
/etc/profile.d (needs root) vs. your own login profile. |
--panel |
full | server | docker | health | traefik |
full |
Which command to run; repeatable. |
--shell |
auto | bash | zsh |
auto |
Target profile; zsh uses zprofile (zsh does not read /etc/profile). |
--uninstall |
— | — | Remove a previous install. |
--dry-run |
— | — | Show what would change, write nothing. |
Global vs. user gives you flexibility: roll it out for everyone via
/etc/profile.d, or let individual users opt in (or override the global one)
from their own profile. The snippet only runs for login shells (SSH logins,
bash -l) and only when interactive — it renders once at login; resizing the
window afterwards re-renders on the next login.
To avoid a duplicate static banner, make sure no update-motd.d hook is
installed and, if present, empty /etc/motd (and optionally set PrintMotd no
in /etc/ssh/sshd_config).
Fallback: update-motd.d (fixed width, not recommended here)
If you must use the classic MOTD mechanism, drop a one-line hook
(exec status-full) into /etc/update-motd.d/ and set a fixed wide width in
the config — accepting that it will not adapt to each login's window. For a
mix of 4K and laptop screens that is the wrong trade-off; prefer install-panel.
Service descriptions (Docker Swarm)
Services are grouped by their com.docker.stack.namespace label (set
automatically for stack deployments). To show a human-readable description per
service, add a label to the service — by default the key
status.description:
services:
postgres:
image: postgres:18
deploy:
labels:
status.description: "PostgreSQL database, version 18"
Change the read label key via docker.description_label in the config.
Before this key was renamed it was lmu.service.description. That key is still
read whenever the configured one is absent from a service, so an existing
deployment keeps its descriptions without any change. Where a service carries
both, the configured key wins — otherwise a service could be pinned to an older
text it had been migrated away from. The rule is presence, not content: a
service that sets the configured key to an empty string is saying "no
description here", and the legacy key is not consulted.
OS logos
Logos are pre-rendered from real PNGs into half-block ANSI (▀ with
fore/background colours) and bundled under
src/terminal_status_panel/render/logos/*.ans. They are plain ANSI, so they
render in MOTD and over SSH without any image protocol or runtime dependency.
The correct logo is chosen automatically — by platform first, then by
detected distribution, then Tux — as described under
Platform behaviour.
To regenerate them (dev only, needs Pillow — pip install -e '.[dev]'), drop
source PNGs into assets/logos/ and run:
python tools/generate_logos.py
Development
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev,test]'
ruff check src tests
python -m pytest
CI (.github/workflows/ci.yml) runs ruff and the test suite on every push and
pull request across Python 3.11–3.13.
Publishing to PyPI
Releases are automated. Pushing a version tag vX.Y.Z triggers
.github/workflows/release.yml, which builds the sdist + wheel and publishes to
PyPI via Trusted Publishing (OIDC) — no API token is stored in the repo.
Release steps:
# 1. bump the version in pyproject.toml (must match the tag)
# 2. commit, then tag and push
git commit -am "release: v0.1.0"
git tag v0.1.0
git push && git push --tags
One-time setup (before the first release):
- On https://pypi.org → Your projects → Publishing, add a pending
trusted publisher with:
- PyPI project name:
terminal-status-panel - Owner:
edutap-collective - Repository name:
terminal_status_panel - Workflow name:
release.yml - Environment name:
pypi
- PyPI project name:
- In the GitHub repo → Settings → Environments, create an environment named
pypi(optionally add required reviewers to gate publishes).
License
Licensed under the EUPL-1.2.
Release files for terminal-status-panel 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| terminal_status_panel-0.4.0.tar.gz | 182.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| terminal_status_panel-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 297.1 kB
Release files / terminal_status_panel-0.4.0.tar.gz
| Download URL | terminal_status_panel-0.4.0.tar.gz |
|---|---|
| Size | 182.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
78fae219940020d1fe2ea2d60b2ac99871f547f76c918af103ce973b3328d3a5
|
|
BLAKE2b-256 checksum How to use checksums |
fa03472dff5d809a651b0ebc07623608234f3fc4493533cfedcce279c04459dd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 8, 2026.
Transparency logRelease files / terminal_status_panel-0.4.0-py3-none-any.whl
| Download URL | terminal_status_panel-0.4.0-py3-none-any.whl |
|---|---|
| Size | 114.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
697278a6c6ef4643aab359eeb838c1d7fcf68f41bd75499645e7e31a35199e65
|
|
BLAKE2b-256 checksum How to use checksums |
73bd95edb3941feae2f823c27c3a9fe8e4e5c5faa0c8b9cb1c8dd268b754c39d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 8, 2026.
Transparency log