Overview
1.1 · What ngtop is
ngtop is a system monitor for the terminal. It joins the processor, memory and process view of btop, the GPU view of nvtop and the network, disk and service views of a graphical task manager in one full-screen interface with five sections. It is one static executable for Linux x86_64: copy it to any distribution and it runs, with nothing to install. This manual explains how it works, for whoever wants to understand it, maintain it or extend it.
| Item | Value |
|---|---|
| Version | 1.0.0 (Cargo.toml) |
| Language | Rust, edition 2024; interface drawn with ratatui 0.30 on crossterm 0.29 |
| Target | x86_64-unknown-linux-musl, fully static (ch. 9 “The static build”) |
| Sections | Overview, GPU, Processes, Network & Disks, Services (keys 1 to 5) |
| Data | Only the kernel's text files, a few system calls through rustix, and D-Bus for systemd (ch. 4 “Data sources”) |
| Privileges | None required. Whatever needs more (other users' file descriptors, power, GPU clients of other users) is shown as “—” when it cannot be read |
| GPUs | AMD (amdgpu) and Intel (i915, xe) are read; NVIDIA is only detected |
| Licence | PolyForm Noncommercial 1.0.0 (LICENSE.md): free for noncommercial use |
| Size | 19,322 lines of Rust, unit tests included; 177 test functions |
1.2 · Design principles
The design is the same in every file. These rules were fixed before the code was written and they explain choices that would otherwise look odd, such as a services thread that never shares a lock with the interface, or a signal dialog that checks a process twice.
| Principle | What it means in the code | Where to see it |
|---|---|---|
| Collect and draw are separate | A collector reads files and computes numbers; it has no idea that a screen exists. A view reads the numbers; it never opens a file. The two halves can be tested apart | src/collect/ and src/ui/; ch. 3 “Architecture” |
| One root for every path | Every collector receives the roots of /proc, /sys and /etc as a Roots value. Tests and screenshots point it at a fake tree | Roots in src/collect/mod.rs |
| No panic on system data | A missing file, a permission error or a strange value gives None or “—”, never a crash. Arithmetic on counters is saturating; every number that is drawn is clamped | ch. 12 “Conventions” |
| Pure Rust dependencies | No crate that compiles C code, so the result is one static file | ch. 9 “The static build” |
| Never wake a sleeping GPU | The metrics of a discrete card in a low-power state are not read; its clients are still counted from the files of the processes | ch. 7 “Safety” |
| Never act on the wrong process | A signal goes to a process identified by its pid and its start time, through a pidfd when the kernel has one | 7.1 “Signals: pidfd and start time” |
| The interface never waits | Everything slow (D-Bus, subprocesses, statvfs on a hung network mount) runs in another thread; the interface asks and later collects the answer | 3.4 “Threads” |
| Cheap by design | Static data is read once, slow-changing data every few cycles, expensive data only when the section that needs it is visible | ch. 8 “Performance” |
| Say what is missing | A section without its data says so on screen (no GPU detected, systemd not found) instead of showing zeros | ch. 6 “The sections” |
1.3 · The project in numbers
The numbers below are counted from the sources every time this manual is built, so they cannot drift. Unit tests live next to the code they test, inside the same files, and are counted with them.
| Part | Where | Lines | Contents |
|---|---|---|---|
| Collectors | src/collect/ | 6,505 | Readers of /proc, /sys, D-Bus; the services thread; unit tests |
| Interface | src/ui/ | 10,425 | Application state, widgets, the five sections, colours, time zone; unit tests |
| Entry point and settings | src/main.rs, src/lib.rs, src/config.rs | 1,082 | Command line, diagnostics modes, the configuration file |
| Integration tests | tests/ | 1,310 | Collectors and views on fake system trees |
| Build and tools | build.sh, scripts/, site/ | 1,347 | Static build, distribution test, third-party licence list, the web site |
| This manual | docs/sources/ | 4,948 | The generator, the style and one file per chapter |
| Total | 25,617 |
Besides code, tests/fixtures/ holds the fake system trees (snapshots of /proc, /sys and /etc reduced to the files ngtop reads), and mockup/ holds the HTML mock-ups the interface was designed from; mockup/app.html is the visual reference.
Source tree and tools
2.1 · The repository
Everything is in one Cargo package. The library holds the collectors and the interface; the program is a thin main.rs on top of it, so the integration tests can call the same functions the program does.
NG-TOP/ ├── Cargo.toml # one package, release profile tuned for size and speed ├── build.sh # static build + check ├── src/ │ ├── main.rs # entry point, diagnostic modes │ ├── lib.rs # library root │ ├── config.rs # command line and configuration file │ ├── collect/ # collectors: no interface code │ │ ├── mod.rs # System, Roots │ │ ├── cpu.rs mem.rs load.rs sensors.rs │ │ ├── process.rs procdetail.rs │ │ ├── net.rs disk.rs conn.rs gpu.rs │ │ └── services/ # systemd: backend, worker thread, cgroup, fake │ └── ui/ # interface: ratatui │ ├── mod.rs app.rs view.rs keys.rs doc.rs history.rs fmt.rs theme.rs tz.rs snapshot.rs │ ├── widgets/ # Panel, BrailleGraph, Meter, Sparkline, ProcTable, TabBar, KeyBar, Dialog │ └── sections/ # one file per section; servizi/ is a folder ├── tests/ # integration tests │ └── fixtures/ # fake /proc, /sys, /etc trees ├── scripts/ # test-distro.sh, third-party.py ├── site/ # landing page, publishing, licence page ├── mockup/ # HTML mock-ups of the interface (visual reference) ├── docs/ # these manuals │ └── sources/ # generator, style, chapters └── dist/ # the finished binary (not in the repository)
panoramica.rs, processi.rs, retedischi.rs, servizi/). The screen names are English: Overview, Processes, Network & Disks, Services. The correspondence is in ch. 13 “Glossary”.2.2 · Tools and build commands
Four tools are enough to build and test ngtop. Rust is installed with rustup in ~/.cargo/bin; add it to PATH if it is missing.
| Command | What it does |
|---|---|
cargo build | Debug build for the machine's own libc, for quick tries |
cargo test | All unit and integration tests |
cargo clippy --all-targets -- -D warnings | Lint; warnings are errors |
cargo fmt | Formatting |
build.sh | Release build for x86_64-unknown-linux-musl, copy to dist/ngtop, check that it is static |
scripts/test-distro.sh | Runs dist/ngtop in containers of several distributions (ch. 10 “Testing”) |
scripts/third-party.py | Regenerates the list of third-party licences |
python3 docs/sources/build.py | Regenerates these manuals (--check verifies they match the code) |
What build.sh does. It stops at the first error (set -euo pipefail), puts ~/.cargo/bin on the path, runs cargo build --release --target x86_64-unknown-linux-musl, copies the result to dist/ngtop (after removing the old file, so a running copy does not give “text file busy”), and finally runs ldd on it. If ldd does not say “not a dynamic executable” or “statically linked”, the script prints the dependencies and exits with an error. Only then is the file called static: see ch. 9 “The static build”.
The release profile (Cargo.toml): opt-level = 3, lto = "fat" (the whole program is optimised together), codegen-units = 1 and strip = true (no symbols). It is slower to compile and makes the file smaller and faster.
$ bash build.sh
...
OK: dist/ngtop is static (3.5M)2.3 · Folders outside src
Each folder outside src/ has one job.
| Folder | Job |
|---|---|
scripts/ | test-distro.sh starts the binary in containers (podman, rootless) of eight distributions and checks exit codes and output; third-party.py reads cargo tree and cargo metadata and writes the licence list of the crates in the binary |
site/ | The web site of the project: the landing page with a demonstration terminal, the licence page built from LICENSE.md, and publish.sh that builds site/public/ and uploads it. The manuals of docs/ are published with it |
mockup/ | HTML pages from which the interface was designed. mockup/tui.js holds the original gradients; src/ui/theme.rs repeats them in Rust |
tests/fixtures/ | Fake system trees, described in ch. 10 “Testing” |
docs/ | The two manuals, decisions-and-history.md (why ngtop is the way it is) and the generator in docs/sources/ |
dist/ | Output of build.sh. Not part of the sources |
target/ | Cargo's build folder. Not part of the sources |
2.4 · The file map
Every source file with its line count. The counts are taken from the files at every build of the manual. A file that exists but is not described here would appear under “Other files” at the end, with a dash instead of a role: that is the sign that this list needs a new line.
| File | Lines | Role |
|---|---|---|
| Entry point and settings | ||
src/main.rs | 407 | Starts ngtop: reads the command line, runs the interface, or one of the diagnostic modes (--dump, --snapshot, --bench, --bench-start, --doc-json) |
src/lib.rs | 8 | Library root: declares collect, config and ui, so the tests can use the program as a crate |
src/config.rs | 667 | Command line, the configuration file (a small TOML subset), merging of the two, atomic saving with W |
| Collectors: src/collect/ | ||
src/collect/mod.rs | 303 | System, the state of the whole machine, and Roots; refresh() runs every collector in order |
src/collect/cpu.rs | 197 | CPU times from /proc/stat, model and topology, frequencies |
src/collect/mem.rs | 100 | Memory and swap from /proc/meminfo |
src/collect/load.rs | 78 | Load average and uptime |
src/collect/sensors.rs | 96 | CPU temperature: which hwmon or thermal zone to read |
src/collect/process.rs | 604 | Every process from /proc/[pid]: CPU%, caches, tree order, sorting |
src/collect/procdetail.rs | 709 | The extra detail of the one selected process (threads, files, sockets, environment, limits, cgroup) |
src/collect/net.rs | 360 | Interfaces, rates, routes, addresses, DNS |
src/collect/disk.rs | 902 | Disks (IOPS, latency, queue), mounts, and the statvfs worker threads |
src/collect/conn.rs | 572 | TCP/UDP connections and the socket-inode to pid cache |
src/collect/gpu.rs | 954 | GPU discovery, amdgpu / i915 / xe metrics, per-process use from fdinfo |
src/collect/services/mod.rs | 407 | systemd data model, the Backend trait, boot phases, parsers of journal and blame output |
src/collect/services/dbus.rs | 604 | The real backend: D-Bus through zbus, subprocesses with a time limit |
src/collect/services/worker.rs | 277 | The services thread and its request / response channels |
src/collect/services/cgroup.rs | 132 | Memory and CPU% of a unit, read straight from its cgroup |
src/collect/services/fake.rs | 210 | A fake backend with demo data, for tests and screenshots |
| Interface: src/ui/ | ||
src/ui/mod.rs | 171 | Terminal set-up and restore, signal flags, the event loop |
src/ui/app.rs | 1,060 | App: all interface state, key and mouse handling, histories |
src/ui/view.rs | 127 | Draws one frame: tab bar, the section, key bar, status line, help and dialogs |
src/ui/keys.rs | 177 | The key tables: bottom bars and the F1 help. The single source, also used by --doc-json |
src/ui/doc.rs | 105 | The hidden --doc-json output: keys and command-line options as JSON, for the manuals |
src/ui/history.rs | 104 | Fixed-size ring buffer of samples |
src/ui/fmt.rs | 126 | Number formatting: bytes, rates, uptime, temperature |
src/ui/theme.rs | 265 | Colours, gradients, and the conversion to 256 and 16 colours |
src/ui/tz.rs | 581 | Local time zone in pure Rust (TZ variable, TZif files) |
src/ui/snapshot.rs | 196 | Renders one frame on a test backend, as text or ANSI colours |
src/ui/widgets/mod.rs | 69 | Safe writing into the buffer (put, putn, clip) |
src/ui/widgets/braille.rs | 126 | The braille graph |
src/ui/widgets/meter.rs | 82 | The bar of filled cells |
src/ui/widgets/sparkline.rs | 69 | The one-line sparkline |
src/ui/widgets/panel.rs | 116 | The panel with a rounded border and titles on the border |
src/ui/widgets/proctable.rs | 254 | Sortable table with fixed and flexible columns |
src/ui/widgets/tabbar.rs | 128 | The top line: brand, tabs, host information |
src/ui/widgets/keybar.rs | 74 | The bottom key bar |
src/ui/widgets/dialog.rs | 114 | The centred dialog window |
src/ui/sections/mod.rs | 10 | Declares the five sections |
src/ui/sections/panoramica.rs | 933 | Section 1, Overview, large and compact layouts |
src/ui/sections/gpu.rs | 731 | Section 2, GPU, and the GPU pieces the Overview reuses |
src/ui/sections/processi.rs | 1,973 | Section 3, Processes: table, detail tabs, signals, nice, folding |
src/ui/sections/retedischi.rs | 1,041 | Section 4, Network & Disks |
src/ui/sections/servizi/mod.rs | 959 | Section 5, Services: state, scheduling of requests, keys |
src/ui/sections/servizi/draw.rs | 834 | Drawing of the Services section |
| Tests | ||
tests/cli.rs | 97 | The program without a terminal: help, version, refusal to start |
tests/collect.rs | 163 | Collectors on two fake snapshots of the system |
tests/gpu.rs | 316 | GPU collectors and views on the GPU fixtures |
tests/retedischi.rs | 240 | Network, disks and connections: collectors, views, keys |
tests/servizi.rs | 344 | The Services section with a fake backend |
tests/ui.rs | 150 | Overview at many sizes, many-core machines |
| Build, tools and web site | ||
Cargo.toml | 23 | Dependencies, release profile (LTO, one codegen unit, strip) |
build.sh | 19 | Static build and the check that the result has no dynamic dependencies |
scripts/test-distro.sh | 88 | Runs the static binary in containers of several distributions |
scripts/third-party.py | 149 | Writes the list of third-party licences of the crates compiled into the binary |
site/publish.sh | 122 | Builds and publishes the web site |
site/licence-page.py | 190 | Builds the licence page from LICENSE.md |
site/landing/index.html | 231 | The landing page |
site/landing/ngterm.css | 70 | Style of the demonstration terminal on the page |
site/landing/ngterm.js | 194 | The demonstration terminal |
site/landing/tui.js | 284 | The interface drawing code of the demonstration |
| This manual | ||
docs/sources/build.py | 1,306 | Generates the two manuals: text, table and SVG figure helpers, numbering, the file map and the numbers, the screenshots, and the --check checks |
docs/sources/manual.js | 326 | The script embedded in the page: sidebar, search, figure zoom |
docs/sources/style.css | 148 | The common style sheet every manual page starts with |
docs/sources/technical/ch01_overview.py | 102 | Technical Manual, chapter 1: Overview |
docs/sources/technical/ch02_source_tree.py | 207 | Technical Manual, chapter 2: Source tree and tools |
docs/sources/technical/ch03_architecture.py | 166 | Technical Manual, chapter 3: Architecture |
docs/sources/technical/ch04_data_sources.py | 349 | Technical Manual, chapter 4: Data sources |
docs/sources/technical/ch05_ui_framework.py | 226 | Technical Manual, chapter 5: The UI framework |
docs/sources/technical/ch06_sections.py | 145 | Technical Manual, chapter 6: The sections |
docs/sources/technical/ch07_safety.py | 164 | Technical Manual, chapter 7: Safety |
docs/sources/technical/ch08_performance.py | 65 | Technical Manual, chapter 8: Performance |
docs/sources/technical/ch09_static_build.py | 76 | Technical Manual, chapter 9: The static build |
docs/sources/technical/ch10_testing.py | 126 | Technical Manual, chapter 10: Testing |
docs/sources/technical/ch11_extending.py | 125 | Technical Manual, chapter 11: Extending ngtop |
docs/sources/technical/ch12_conventions.py | 52 | Technical Manual, chapter 12: Conventions |
docs/sources/technical/ch13_glossary.py | 61 | Technical Manual, chapter 13: Glossary |
docs/sources/user/ch01_introduction.py | 61 | User Manual, chapter 1: Introduction |
docs/sources/user/ch02_getting_started.py | 106 | User Manual, chapter 2: Getting started |
docs/sources/user/ch03_the_screen.py | 134 | User Manual, chapter 3: The screen |
docs/sources/user/ch04_overview.py | 106 | User Manual, chapter 4: Overview |
docs/sources/user/ch05_gpu.py | 111 | User Manual, chapter 5: GPU |
docs/sources/user/ch06_processes.py | 155 | User Manual, chapter 6: Processes |
docs/sources/user/ch07_network_disks.py | 117 | User Manual, chapter 7: Network & Disks |
docs/sources/user/ch08_services.py | 148 | User Manual, chapter 8: Services |
docs/sources/user/ch09_configuration.py | 103 | User Manual, chapter 9: Configuration |
docs/sources/user/ch10_permissions.py | 67 | User Manual, chapter 10: Permissions and running as root |
docs/sources/user/ch11_troubleshooting.py | 107 | User Manual, chapter 11: Troubleshooting |
docs/sources/user/ch12_key_reference.py | 44 | User Manual, chapter 12: Key reference |
docs/sources/user/ch13_glossary.py | 45 | User Manual, chapter 13: Glossary |
Architecture
3.1 · Collect and draw
ngtop has two halves. The collectors (src/collect/) read the kernel's files and turn counters into numbers: percentages, rates, latencies. The interface (src/ui/) keeps the recent past of those numbers and draws them. The only thing that ties them is the System structure, which the interface reads and never writes.
System (src/collect/mod.rs) holds the latest value of everything: cpu, mem, load, uptime, processes, net, gpu, disks, conns, cpu_temp and zram. It also holds the previous sample of the counters it needs for differences (for example prev_cpu, prev_cores and prev_procs). A collector never stores history: it knows only “now” and “the sample before”.
App (src/ui/app.rs) keeps what the interface needs to remember: the histories (cpu_hist, mem_hist, and one per network interface, disk and GPU), the process list in the order shown, the selection, the filter, the open dialog and the state of each section. After every refresh, App::update() pushes the new values into the histories (600 samples each, HISTORY_LEN) and rebuilds the process list.
Roots, the same code runs on the real machine, on a fake tree in a test, and behind ngtop --snapshot when a screenshot is made for these manuals (ch. 10 “Testing”).3.2 · One refresh, step by step
System::refresh() measures the time since the previous call with the real clock and passes it to refresh_with_dt(dt). Tests call refresh_with_dt directly with a chosen dt, so rates do not depend on how fast the test runs. With dt <= 0 (the first sample) every rate is zero.
| Order | Step | Function | How often |
|---|---|---|---|
| 1 | CPU times and per-core frequency | refresh_cpu() | every cycle |
| 2 | Memory, load average, uptime | read_meminfo(), read_loadavg(), read_uptime() | every cycle |
| 3 | All processes | refresh_processes() → read_all() | every cycle; owner, command line and ids are cached |
| 4 | GPUs and per-process GPU use | refresh_gpu() → GpuCollector::refresh() | every cycle; open DRM files are rescanned every 5th or 20th cycle |
| 5 | Network interfaces | net::refresh() | counters every cycle; routes and addresses every 5th |
| 6 | Disk I/O | disk::refresh_io() | every cycle |
| 7 | Filesystems | FsMonitor (request(), poll(), snapshot()) | mount list and a new request every 5th cycle; answers collected every cycle |
| 8 | Connections | conn::refresh_with() | only if the Network & Disks section is visible, and then every 2nd cycle |
| 9 | CPU temperature, ZRAM | read_temp(), read_zram() | every cycle (the sensor file is found once) |
Step 4 also adds to each process the GPU share and GPU memory it found, so the process table can sort by GPU without knowing where the number came from. Details of each step are in ch. 4 “Data sources”.
3.3 · The event loop
The loop is in event_loop() (src/ui/mod.rs). It is single-threaded and sleeps most of the time.
| Rule | Value | Why |
|---|---|---|
| First tick | 250 ms after start | Percentages appear almost at once instead of after a whole second |
| Interval | INTERVALS_MS = 250, 500, 1000, 2000, 5000 ms; default 1000 | Keys + and - step through the list; the configuration is rounded to the nearest step (round_interval()) |
| Longest sleep | 250 ms | So that SIGTERM, SIGINT and SIGHUP are noticed quickly |
| Sleep while waiting for services | 100 ms | App::background_active() is true while the Services section has a request in flight |
| Redraw | only when something changed | A flag, dirty, is set by a key, mouse or resize event that changed the state, by a service answer, or by a tick. An idle ngtop does not redraw |
| Interval change | at once | The next tick is brought forward if the new interval is shorter than the time left |
| Queued events | all read before the next draw | Holding a key does not queue a hundred redraws |
At the start, run() does everything that can fail or allocate a lot (time zone, the first sample) before it touches the terminal. Then it switches to raw mode and the alternate screen, optionally turns on the mouse, and enters the loop. How the terminal is given back, even after an error or a signal, is in 7.6 “Terminal restore”.
3.4 · Threads
ngtop starts three kinds of thread, and none of them is a pool: each exists because one call can block.
| Thread name | Started by | Lives | Does | Talks to the interface through |
|---|---|---|---|---|
| (main) | the process | whole run | event loop, all collectors except the ones below, drawing | — |
ngtop-statvfs | FsMonitor::set_mounts() | while its mount exists | statvfs on one mount point. A hung network mount blocks only its own thread | a request channel in, one shared answer channel out |
ngtop-services | Client::spawn() | from the first visit to the Services section to exit | executes Request values: unit lists, details, sessions, boot times | two mpsc channels |
ngtop-journal, ngtop-blame | run() in worker.rs | one request | run journalctl or systemd-analyze blame with a time limit | the services answer channel |
ngtop-action | run() in worker.rs | one action | start, stop, restart, enable or disable a unit. At most one at a time; a second one is refused at once | the services answer channel |
ngtop-unitfiles | unit_file_states() | one refresh | reads the list of unit files (a slow D-Bus call) and stores it in a cache | a shared cache |
The pattern is always the same: the interface sends a request and goes on; later poll() takes whatever answers have arrived, without waiting (try_recv). The interface can therefore draw, scroll and take keys while systemd is slow or absent. The tests l_interfaccia_non_attende_il_backend and un_azione_lenta_non_blocca_la_coda_e_ce_ne_e_una_sola check it with a deliberately slow backend.
signal_hook installs handlers that set an atomic flag; the event loop reads the flag. See 7.6 “Terminal restore”.3.5 · State and drawing
The interface state is one App value passed by reference to the drawing code. The pieces that are specific to a section live in their own structure, so a section can be read on its own.
| State | Type | Holds |
|---|---|---|
app.proc | ProcState | detail tab, folded branches, scroll, the status line, the signal and start-time functions (replaceable in tests) |
app.rd | RdState | connection list, filter, sort, selected row, disk scroll |
app.servizi | Servizi | unit list, filter chip, search, selection, pending requests, the confirmation, the services client |
app.rows / app.view | Vec<Process> | all processes, sorted; and the indices actually shown (after tree order, folding and filter) with their tree prefix |
app.selected_pid | Option<u32> | the selection is kept as a pid, so it stays on the same process when the list is reordered |
app.hits | Hits | click targets computed by the last draw: tab ranges, the table area, the columns. The mouse handler uses them; there is no separate hit-testing pass |
Layout mode. LayoutMode::for_size() chooses between large and compact: compact when the terminal is narrower than 100 columns or lower than 30 rows. The mode is recomputed on every resize event; only the Overview has a different compact layout (6.2 “The Overview section”).
Drawing. view::draw() splits the screen into the top line, the body and the bottom line, asks the active section to draw the body, then draws the key bar for that section, the status line of signals and nice (which replaces the key bar for five seconds), and last the help or the dialog on top. Every widget writes into the same ratatui Buffer (ch. 5 “The UI framework”).
Data sources
4.1 · How the collectors read
A collector is a function that reads a few small text files and returns a plain value. All of them follow the same four rules, which is why a missing file never becomes an error on screen.
| Rule | In the code |
|---|---|
| The root is a parameter | Paths are built from roots.proc, roots.sys and roots.etc. On a real machine they are /proc, /sys, /etc; in a test they point into tests/fixtures/ |
| Reading cannot fail | read_text() in src/collect/mod.rs returns Option<String>: None if the file is missing or unreadable, and invalid UTF-8 is replaced, not rejected |
| Parsing is separate from reading | Each file has a parse_*() function that takes text. The tests feed it strings, so no file is needed |
| Rates need two samples | A rate is (now − before) / dt. The first sample has no “before”, so its rates are zero. Counters that go backwards (a reset) are clamped with saturating_sub, never negative |
| Data | Read from | Collector | Needs privileges? |
|---|---|---|---|
| CPU | /proc/stat, /proc/cpuinfo, /sys/devices/system/cpu/cpuN/cpufreq/scaling_cur_freq | cpu.rs | no |
| CPU temperature | /sys/class/hwmon/*/, /sys/class/thermal/ | sensors.rs | no |
| Memory, swap | /proc/meminfo, /sys/block/zram*/mm_stat | mem.rs, mod.rs | no |
| Load, uptime | /proc/loadavg, /proc/uptime | load.rs | no |
| Processes | /proc/[pid]/stat, status, cmdline | process.rs | no |
| Selected process | /proc/[pid]/ many files | procdetail.rs | partly (other users' io, fd, environ) |
| Network | /proc/net/dev, route, fib_trie, if_inet6, /sys/class/net/*/, /etc/resolv.conf | net.rs | no |
| Connections | /proc/net/{tcp,tcp6,udp,udp6}, /proc/[pid]/fd | conn.rs | owners of other users' sockets need root |
| Disks | /proc/diskstats, /sys/block/*/ | disk.rs | no |
| Filesystems | /proc/self/mountinfo, statvfs | disk.rs | no |
| GPU | /sys/class/drm/card*/device/, /proc/[pid]/fdinfo/* | gpu.rs | other users' clients need root |
| Services | systemd and login1 on D-Bus, /sys/fs/cgroup/, journalctl, systemd-analyze | services/ | starting and stopping asks polkit |
| Time zone | TZ, /etc/localtime | src/ui/tz.rs | no |
4.2 · CPU, temperature
The processor view is built from two files and one directory.
| Quantity | Formula | Where |
|---|---|---|
Fields of a cpu line | user nice system idle iowait irq softirq steal. The sum of the first eight is the total; idle is idle + iowait. (guest is already counted inside user, so it is not added.) | parse_times() |
| Usage between two samples | Δtotal = totalnow − totalbefore; Δidle likewise (capped at Δtotal). usage % = (Δtotal − Δidle) × 100 / Δtotal; zero if Δtotal is zero | usage_between() |
| Total and per core | The cpu line gives the total, each cpuN line one core. A core that goes offline simply stops appearing | parse_stat() |
| Frequency | scaling_cur_freq is in kHz; the value shown is kHz / 1000 MHz. Missing on some machines: “—” | read_freq_mhz() |
| Model | The first model name line of /proc/cpuinfo | parse_model() |
| Logical and physical cores | Logical = number of processor blocks. Physical = number of distinct pairs (physical id, core id); unknown if the file has no such lines (virtual machines, some ARM boards) | parse_topology() |
Temperature. find_cpu_temp_path() looks once, at the first refresh, for the file to read, and the answer (even “none”) is remembered. It scans /sys/class/hwmon/hwmon* in name order and accepts the first sensor whose name is known: coretemp (label “Package id 0”), k10temp (labels “Tctl” then “Tdie”), zenpower (“Tdie” then “Tctl”) and cpu_thermal / cpu-thermal. If the wanted label is not found it falls back to temp1_input. If no hwmon matches, it looks for a thermal zone of type x86_pkg_temp. The value is in thousandths of a degree and is divided by 1000. The same read_temp() reads disk and GPU temperatures.
4.3 · Memory, swap, load
Memory comes from one file, /proc/meminfo, whose values are in kB; the parser multiplies by 1024 (a value with no unit is taken as bytes). Missing keys count as zero.
| Shown as | Formula | Note |
|---|---|---|
| Used | MemTotal − MemAvailable | Not “total − free”: cache the kernel can give back is not counted as used |
| Available | MemAvailable | If the kernel is too old to have it: MemFree + Buffers + Cached, at most the total |
| Cache | Cached + SReclaimable | Page cache plus reclaimable kernel memory |
| Buffers, Free | Buffers, MemFree | |
| Swap used | SwapTotal − SwapFree | |
| zswap | Zswap (compressed size) and Zswapped (original size) | Only if the kernel exposes them |
| Dirty | Dirty | Pages waiting to be written |
| ZRAM | Sum, over /sys/block/zram*/mm_stat, of the first two numbers: original data size and compressed size | The ratio original / compressed is shown as “2.5×”. Not shown if there is no ZRAM or it is empty |
Load and uptime. /proc/loadavg: the first three numbers are the 1, 5 and 15 minute averages; the fourth, “running/total”, gives the number of runnable and existing scheduling entities. /proc/uptime: seconds since boot, and idle seconds summed over all cores. The tab bar shows uptime as 3d 04:12 or 04:12:05.
4.4 · Processes
Reading every process is the most expensive part of a refresh, so process::read_all() is written to do as little as possible per process. For each directory in /proc whose name is a number it reads stat every time and the other files only when needed.
Field of stat | Index after the last “)” | Used for |
|---|---|---|
| state | 0 | R, S, D, Z, T… shown in the detail |
| ppid | 1 | the tree |
| utime, stime | 11, 12 | CPU time (clock ticks) |
| priority, nice | 15, 16 | priority and nice in the General tab; nice is what F7 / F8 change |
| num_threads | 17 | thread count |
| starttime | 19 | start (ticks since boot): the identity of a process, with its pid |
| rss | 21 | resident pages; × the page size = bytes |
The name (comm) is in parentheses and may contain spaces and even a “)”, so parse_stat() takes the text between the first “(” and the last “)”, and counts fields after it.
| File | When it is read | What for |
|---|---|---|
stat | every cycle, every process | everything in the table above |
status | for a new process; again every 15th cycle, staggered by pid | the owner (first number of the Uid: line). If status cannot be read, the owner of the /proc/[pid] directory is used |
cmdline | for a new process, or after the name or start time changed (an exec) | the full command; NUL bytes become spaces; an empty one (kernel threads) is shown as [name] |
io | not in normal runs | The code path exists behind the flag want_io, which nothing turns on, so the process list has no I/O columns. The detail of the selected process reads its own io |
/etc/passwd | once, at start | user ids to names; first name wins for a duplicated id |
CPU % of a process is a share of the whole machine, from 0 to 100:
cpu% = Δ(utime + stime) ÷ hz ÷ dt ÷ ncores × 100, clamped to 0–100
where hz is the number of clock ticks per second (clock_ticks_per_second()), dt is the real time between the two refreshes, and ncores is the number of cores listed in /proc/stat. A process that fully uses one core of eight shows 12.5. The previous sample counts only if it has the same start time: if a pid was reused by a new process, its first sample shows zero instead of a wrong number.
Memory is the resident set: rss pages × page size, with the page size read once from the system.
Tree order. tree_order() puts each process under its parent, children sorted by pid, with the prefixes ├─ , └─ and │ . A process whose parent does not exist is a root; if a loop in the parent ids ever existed, the leftover processes are made roots instead of being lost or looping forever. Sorting (sort_processes()) is always stable: ties are broken by pid.
The selected process. DetailCollector::refresh() reads, only for the pid selected in the Processes section, exe and cwd (links), stat (last core, scheduling policy), status (CPU affinity, swap), cgroup, io and the number of open files. Heavy files are read only when their tab is shown: task/*/stat (Threads, with per-thread CPU %), fd and limits (Files), the socket tables (Net), environ (Env), limits (Limits) and the cgroup files (cgroup). smaps_rollup (PSS, shared, swap) can take tens of milliseconds on a large process, so it is read only on the General tab, when the process changes and then every 5th cycle. Lists are capped: 200 files (MAX_FILES) and 500 environment variables (MAX_ENV).
4.5 · Network
Network data is split into what changes every cycle (the byte counters) and what hardly ever does (routes, addresses, link speed). The second group is reread every 5th cycle.
| Quantity | Source and formula |
|---|---|
| Bytes in / out | /proc/net/dev: field 0 after the colon is received bytes, field 8 is sent bytes |
| Rate | (bytesnow − bytesbefore) ÷ dt, in bytes per second. Zero for a new interface |
| Session peak | The highest rate seen since start (key z in Network & Disks resets it) |
| Link up | /sys/class/net/X/operstate is “up” or “unknown” (tunnels report unknown, and are active) |
| Speed, duplex | /sys/class/net/X/speed (Mbit/s, ignored if not positive) and duplex (ignored if “unknown”) |
| Default route and gateway | /proc/net/route: the route with destination 0 and mask 0 and the lowest metric. Values are hexadecimal, in the byte order of the machine, and converted back to dotted form |
| IPv4 address | /proc/net/fib_trie: the “/32 host LOCAL” entries, not 127.x; an address belongs to the interface whose route has a network that contains it: (address & mask) = destination |
| IPv6 address | /proc/net/if_inet6: the global one if there is one, otherwise the link-local |
| DNS servers | the nameserver lines of /etc/resolv.conf |
The interface shown. NetInfo::pick() chooses the one the user selected, else the interface of the default route, else the first one that is not lo. NetInfo::cycle() moves to the next or previous one and skips lo when others exist.
4.6 · Connections: inode to pid
A line of /proc/net/tcp gives two addresses, the state, the queue sizes and an inode, but not the process. The process is found by looking for a descriptor that points to socket:[inode]. Doing that for every connection on every cycle would open the fd directory of every process, so the result is kept.
| Item | How it is decoded |
|---|---|
| IPv4 address | 8 hex digits: the 32-bit number in the byte order of the machine (to_ne_bytes), then dotted |
| IPv6 address | 32 hex digits: four 32-bit words, each in machine order; shown compressed in brackets |
| Port | hexadecimal |
| TCP state | 01 ESTAB, 02 SYN_SENT, 03 SYN_RECV, 04 FIN_WAIT1, 05 FIN_WAIT2, 06 TIME_WAIT, 07 CLOSE, 08 CLOSE_WAIT, 09 LAST_ACK, 0A LISTEN, 0B CLOSING |
| UDP state | 01 (connected) is ESTAB; everything else is “—” |
| Remote “unset” | address 0 and port 0 are shown as * |
| Queues | tx_queue:rx_queue in hex |
The algorithm of conn::refresh_with():
| Step | What happens |
|---|---|
| 1 | Read the four tables and parse them. A malformed line is skipped |
| 2 | Forget cache entries whose inode is no longer in any table |
| 3 | Collect the inodes that must be found: new ones, and the ones that failed earlier only when the retry counter (RETRY_EVERY = 5 cycles) has run out |
| 4 | If there are any, scan_fds() walks the processes: first the pids already in the cache, then the others; it stops as soon as every wanted inode has been found. Pid 2 (kernel threads) is skipped |
| 5 | A process whose fd folder cannot be opened (permission denied, or gone) is remembered with its start time and not tried again while it is the same process |
| 6 | Attach to each connection the pid from the cache, or none |
4.7 · Disks and filesystems
The disk view combines a kernel counter file, a few static files and the mount table.
| Quantity | Formula (two samples of /proc/diskstats, interval dt) |
|---|---|
| Which devices | Whole physical disks only: names starting with loop, ram, zram, dm-, md, sr or fd are skipped; a name is a disk if /sys/block/NAME exists (partitions live inside the disk's folder, not in /sys/block). Without /sys/block the name is judged by pattern (sda1, nvme0n1p2, mmcblk0p1 are partitions) |
| Throughput | sectors × 512; rate = Δbytes ÷ dt, separately for read and write |
| IOPS | Δ(reads completed) ÷ dt and Δ(writes completed) ÷ dt |
| Busy time | util = Δio_ticks ÷ (dt × 1000) (the field is milliseconds spent with I/O in progress), clamped to 0–1 |
| Latency | (Δread_ticks + Δwrite_ticks) ÷ (Δreads + Δwrites) in ms: the average time per operation. No latency if no operation happened |
| Queue | the “I/Os currently in progress” field, as it is now |
| Kind | nvme… is NVMe; otherwise queue/rotational = 1 is HDD, 0 is SSD, missing is unknown |
| Model, size | device/model with spaces squeezed; size × 512. Read once per disk |
| Disk temperature | device/hwmon/hwmon*/temp1_input (NVMe, and SATA with drivetemp); found once |
Filesystems. parse_mountinfo() keeps only the mounts of real filesystem types (ext2/3/4, btrfs, xfs, f2fs, vfat, exfat, zfs, nfs, nfs4, cifs, smb3, ntfs, ntfs3, fuseblk, jfs, reiserfs, bcachefs, hfsplus), skips the mounts under Docker, Podman and snap folders, and drops duplicates (same mount point, or same device and same root: bind mounts of one subvolume). Octal escapes such as \040 in paths are decoded. The disk of a mount is found from its source (disk_of_source()): /dev/nvme0n1p2 is nvme0n1; device-mapper and network sources have none.
| Quantity | Formula (statvfs) |
|---|---|
| Total | f_blocks × f_frsize |
| Used | (f_blocks − f_bfree) × f_frsize |
| Available | f_bavail × f_frsize (what an ordinary user may still write) |
| Fraction used | used ÷ (used + available), the same as df, so the bar fills to 100 % when an ordinary user can write no more, even if the root reserve is free |
| Inodes | (files − ffree) ÷ files, absent if the filesystem has no inode count |
Why threads. statvfs on a network mount whose server has vanished can block for minutes, and nothing can interrupt it. FsMonitor therefore starts one thread per mount point ngtop-statvfs. Every 5th cycle the interface asks each one for a new measurement, but only those with no request in flight; it never queues a second. It collects answers that have already arrived without waiting (only the very first refresh waits, at most 300 ms, so the first screen is not empty). A mount with no answer for more than 5 seconds (FS_TIMEOUT) is shown as unresponsive and the others are unaffected. A mount that disappears from the list has its thread closed.
4.8 · GPU: devices
GPU data comes from two places: the device's own files in sysfs, and, for who uses the GPU, the fdinfo file of every open GPU descriptor of every process.
| State | When | What ngtop does |
|---|---|---|
Active | the card is awake | reads everything |
Idle | an integrated GPU whose runtime power management says “suspended” | reads it normally: it shares the processor's package, so there is nothing to save and nothing to wake |
Suspended | a discrete GPU with power/runtime_status “suspended” or power_state “D3cold” | reads none of its usage, memory, clock, sensor or link files, so it is not woken; the screen says it is asleep and whether it is in D3cold |
Unsupported | any other driver, such as nvidia | shows the name; nothing else |
Identity. Read once per card (read_static()): vendor and device from device/vendor and device/device; PCI slot and driver from device/uevent (PCI_SLOT_NAME, DRIVER); the name from product_name if the card has it, otherwise from the pci.ids database (pci_name(): the bracketed commercial name, with the maker in front), otherwise “AMD GPU [1002:xxxx]”. The PCI slot is the key of the device everywhere in the program.
| Driver | Usage % | Memory | Clocks | Other |
|---|---|---|---|---|
amdgpu | gpu_busy_percent | mem_info_vram_used / total, mem_info_gtt_used / total | pp_dpm_sclk and pp_dpm_mclk: the line with “*” is the current clock, the largest is the maximum | integrated if the VRAM is 1 GiB or less (unified memory); hwmon temperatures, power, fan; PCIe link |
i915 | the busiest engine, from fdinfo | system memory resident for GPU clients (fdinfo) | gt_act_freq_mhz (else gt_cur_freq_mhz), max gt_max_freq_mhz | integrated if the PCI slot starts with 0000:00:; otherwise a discrete Arc card, with PCIe link |
xe | the busiest engine, from fdinfo | as i915 | tile0/gt0/freq0/act_freq (else cur_freq), max max_freq | only tile 0, gt 0 is read |
The temperature shown is the hwmon “junction” sensor, else “edge”, else the first one. Power is power1_average (else power1_input), in microwatts divided by 106; the fan is fan1_input in RPM, or pwm1 ÷ 255 as a percentage. The PCIe text “4.0 ×16” is built from current_link_speed in GT/s (2, 5, 8, 16, 32, 64 map to PCIe 1.0 to 6.0) and current_link_width.
4.9 · GPU: processes and the fdinfo files
Modern kernels (amdgpu, i915, xe) publish, for each open GPU descriptor, a file /proc/[pid]/fdinfo/N with the DRM client's counters. That is the only source of “who is using the GPU”, and it needs no special library.
| Key in fdinfo | Meaning | Used as |
|---|---|---|
drm-driver | drm-pdev | the card the client works on |
drm-client-id | the client's number, unique on that card | with drm-pdev, the client's identity |
drm-engine-NAME | nanoseconds of work done on engine NAME, cumulative (amdgpu, i915) | engine busy % |
drm-engine-capacity-NAME | how many identical engines NAME stands for | divisor of the busy % |
drm-cycles-NAME, drm-total-cycles-NAME | cycles used and cycles elapsed (xe) | engine busy % |
drm-memory-vram, drm-resident-vram* / local* | video memory of the client | VRAM; resident is preferred when present, and the two families are never added |
drm-memory-gtt, drm-resident-system* | system memory the GPU maps | GTT; memory shown for integrated GPUs |
Finding the files. A process's GPU descriptors are the ones in /proc/[pid]/fd that point to /dev/dri/…. Listing them for every process every cycle would be slow, so the answer is cached per pid with the process's start time. A new pid is scanned at once; a process that has GPU descriptors is rescanned every 5th cycle, one without every 20th (both staggered by pid so not all happen together); a process whose fd folder was denied is not scanned again until it is a different process. Kernel threads are never scanned. Then only the known descriptors are read, each cycle.
One client, many processes. A DRM client can be reachable from several processes at once (a parent and its forked children share the descriptor). If each were counted, one workload would appear many times. The collector walks pids in ascending order and keeps the first process that shows a given (card, client id); the others are ignored. The client is attributed to the lowest pid that holds it.
| Quantity | Formula |
|---|---|
| Engine busy % of a client | Time counters: Δns ÷ (dt × 109) ÷ capacity × 100. Cycle counters: Δused ÷ Δtotal × 100. Clamped to 0–100. A client seen for the first time has no “before”: zero |
| Engine kind | By the name prefix: enc encode; dec, vcn, jpeg decode; video, vcs, vecs video; compute, ccs compute; copy, dma, bcs, sdma copy; anything else (gfx, render) is graphics |
| GPU % of a process | The busiest of its graphics, compute and copy engines, after adding up all its clients on each engine, at most 100. It is the engine that limits the work, so sums of different engines are not used |
| Encode and decode % of a process | The sums of its encode engines, and of its decode and video engines, at most 100 |
| GPU memory of a process | VRAM if the client has any, otherwise its resident system memory |
| GPU % shown in the process table | The sum over the cards the process uses, at most 100 |
| Usage of a card | amdgpu: the file gpu_busy_percent. If it is missing, and for Intel always, the busiest engine over all clients |
| Kind of work | Graphics if any graphics engine has ever worked, else compute, video, copy; none if all are zero |
4.10 · Services, sessions, boot
The Services section needs systemd. It is available only if /run/systemd/system exists (systemd_present()); otherwise the section says so. All the following happens in the services thread (3.4 “Threads”).
| Data | How | Notes |
|---|---|---|
| List of units | ListUnits on the systemd manager, system or session bus | sorted by name, case-insensitive |
| Enabled / disabled | ListUnitFiles: the unit file state of each name | slow; cached for 60 s (UNIT_FILES_TTL), refreshed in the background; instances of a template (foo@bar.service) take the state of foo@.service |
| Main pid, cgroup | Get of MainPID and ControlGroup, only for active services | two small calls instead of GetAll, which is costly for systemd; the cgroup is kept while the unit lives, the pid is asked again when the sub-state changes or after 10 s |
| Memory and CPU of a unit | straight from the cgroup: memory.current, memory.peak, cpu.stat (usage_usec) | no D-Bus call per row. CPU % = Δusage_usec ÷ 106 ÷ dt ÷ ncores × 100; shown after the second reading |
| Detail of a unit | GetAll on the Unit interface and on the Service / Socket / Mount / Swap / Scope interface | description, states, since when, restarts, memory, tasks, dependencies |
| Sessions | login1 ListSessions and GetAll per session | user, seat, tty, type, host, since |
| Boot times | the manager's Firmware/Loader/InitRD/Userspace/FinishTimestampMonotonic | boot_phases(): firmware = firmware − loader; loader; kernel = initrd (or userspace); initrd = userspace − initrd; services = finish − userspace |
| Slowest units | systemd-analyze blame as a subprocess | parse_blame() reads “1min 2.5s”, “696ms” |
| Journal | journalctl -u NAME -n 50 -o json as a subprocess (--user-unit for the user scope) | 15 s limit; parse_journal_json() skips bad lines, decodes a byte-array message, and sorts by time |
Bus and timeouts. The system scope uses the system bus, the user scope the session bus. Queries wait at most 10 seconds (QUERY_TIMEOUT); actions use a separate connection that waits up to 120 seconds (ACTION_TIMEOUT) because polkit may be waiting for a password. Subprocesses are run by run_with_timeout(), which polls every 20 ms and kills the child at the limit.
The UI framework
5.1 · ratatui and the frame
ngtop uses ratatui 0.30 for what it does well — a screen as a grid of cells, a layout engine, a test backend — and crossterm 0.29 for the terminal. It does not use ratatui's ready-made widgets. Every visible element is a small struct in src/ui/widgets/ with a render(area, buf) method that writes cells straight into the ratatui Buffer. This costs a little code and buys exact control over characters, colours and widths, the same on every terminal.
Drawing is a pure function of App and System. It happens inside Terminal::draw, and ratatui sends the terminal only the cells that changed since the last frame. The same function runs on a ratatui TestBackend in src/ui/snapshot.rs: render_app() returns the buffer, to_text() turns it into lines of text (empty meter cells become “·” so they can be told from full ones) and to_ansi() into text with truecolor escape sequences. This is what ngtop --snapshot WxH prints.
5.2 · Safe writing and widths
Terminals have no exceptions, but the code that writes to a ratatui buffer does: setting a cell outside the buffer panics. A monitor must survive being resized to one row, or to an enormous size. So no widget calls ratatui's writing functions directly.
| Function | File | Rule |
|---|---|---|
put(buf, x, y, text, style) | widgets/mod.rs | Writes nothing if the position is outside the buffer; clips at the right edge |
putn(…, n, …) | widgets/mod.rs | The same, at most n cells |
clip(s, max) | widgets/mod.rs | Cuts a string to max cells, without splitting a wide character |
width(s) | widgets/mod.rs | Width in cells with unicode-width, not the number of characters or bytes |
style_area() | widgets/mod.rs | Applies a style only to the part of a rectangle inside the buffer |
column_spans() | widgets/proctable.rs | Column positions from the widths; the flexible column gets the rest; nothing exceeds the area |
Unicode widths. A character can occupy zero, one or two cells (CJK, many emoji; combining marks take none). Process names and command lines come from the system and can contain anything. Wherever a text is placed in a column (put_line() in proctable.rs), ngtop advances by the width of each character and skips zero-width ones; a wide character that does not fit whole in the column is not drawn, so it can never spill into the next. The characters ngtop draws itself — braille, “■”, block elements, box lines, “▼”, “▲” — are one cell wide.
Arithmetic. Sizes are u16. Wherever a product could overflow on a huge terminal or a machine with thousands of cores, the code widens to u32 first (for example core_layout()) or uses saturating_sub and saturating_add.
tutte_le_misure_e_sezioni_senza_panic draws every section at 81 combinations of width (1 to 182) and height (1 to 52), including one-cell terminals, and renders each to ANSI. It is the guard of this chapter (ch. 10 “Testing”).5.3 · The widgets
All widgets are in src/ui/widgets/. Each takes the data to show and a rectangle.
| Widget | Data | What it draws |
|---|---|---|
Panel | title, right text, bottom text, colour | A rounded border (╭ ╮ ╰ ╯ ─ │) in the given colour. The title is bold on the top edge, the right text dim at the right of the top edge, the bottom text dim on the bottom edge; all clipped to leave the corners. Returns the inner rectangle; a rectangle under 2 × 2 returns an empty one |
BrailleGraph | values 0–1, gradient, direction | A line graph in braille, the newest value at the right (below) |
Meter | fraction 0–1, gradient | A bar of “■” cells. The first round(fraction × width) cells are coloured along the gradient; the rest use the grey EMPTY. NaN and values out of range are corrected |
Sparkline | values 0–1, gradient | One row of ▁▂▃▄▅▆▇█, one per value; the level is floor(value × 8), at most 7; each block takes the gradient colour of its own value. Right-aligned; missing values are blank |
ProcTable | columns, visible rows, selection, sort | A header (dim; the sort column bold with ▼ for descending or ▲ for ascending), then the rows. Cells are ratatui Line values so a cell can have several colours |
TabBar | tab names, active index, right text | “ngtop 1.0 │” then the five tabs as “ 1 Overview ”, and at the right the host, uptime, load, interval and clock if there is room. tab_ranges() gives the click areas |
KeyBar | pairs (key, label) | htop-style bar: the key on a cyan background, the label on a dark one. Items that do not fit are left out whole; the last one (q Quit) is kept when possible |
Dialog | title, lines, buttons, selected | A centred red-bordered window; it clears its background first. Width is the longest line (at least 30) plus 4; height is the lines plus 4 |
The widgets keep no state. All state (selection, scroll offset, histories) is in App.
5.4 · The braille graph
Every graph in ngtop — the CPU history, the network rate, the GPU load — is a braille graph. A braille character (U+2800 to U+28FF) has a grid of 2 × 4 dots, so one terminal cell carries two columns and four levels of the graph: eight times the resolution of the cell, with a single character.
The algorithm is braille_rows(vals, w, h, down) in src/ui/widgets/braille.rs. The graph is w cells wide and h cells high, so it has 2w columns of 4h levels.
| Step | Rule |
|---|---|
| 1. Which values | The last 2w values of the series. If there are fewer, the missing ones are zero and sit at the left: the graph fills from the right as time passes |
| 2. Level of a value | level = round(v × 4h), with v clamped to 0–1 (NaN is 0). Thus level dots must be lit from the base |
| 3. Which cell | Text column c takes values 2c (left dots) and 2c + 1 (right dots) |
| 4. Which dots | Rows are counted from the base: row rb is the one rb cells above it. In that row, dot k (0 = nearest the base, to 3) is lit if level > 4·rb + k |
| 5. The bits | Left column, from the base up: 0x40, 0x04, 0x02, 0x01. Right column: 0x80, 0x20, 0x10, 0x08 (mirrored for down). The sum of the lit bits is added to U+2800 |
| 6. Colour | A whole row has one colour: the gradient at (rb + 0.5) ÷ h. A tall peak therefore turns from green toward red as it climbs |
With down the graph hangs from the top edge and the bit tables are swapped, which gives the mirror image used under a graph that goes up (network received and sent, GPU history). The rate graphs are given values already divided by a “round” maximum: nice_max(v, floor) in fmt.rs returns the smallest power of two that is at least the largest recent value (and at least the floor), so the scale does not jump at every cycle.
braille_rows(&[1.0, 0.0], 1, 1, false) is U+2847 (the whole left column) and &[0.5, 0.5] is U+28E4 (two dots from the base on both sides). The unit test caratteri_attesi checks exactly these.5.5 · Theme and gradients
Colours are defined once, in src/ui/theme.rs, as 24-bit values. They are ten gradients: lists of two to four colour stops between which the program interpolates. They were taken from the mock-up (mockup/tui.js).
grad(stops, t) takes t from 0 to 1 (outside the range or NaN is brought back to 0–1), scales it to the number of segments, finds the segment and mixes the two colours channel by channel: colour = a + (b − a) × f, rounded. One stop gives a flat colour; none gives the default text colour.
| Constant | Value | Use |
|---|---|---|
FG, DIM, WHITE | #c9d1d9, #6e7681, #f0f6fc | text, secondary text, emphasis |
DARK, EMPTY | #0b0f14, #21262d | text on a bright background; the unfilled part of a meter |
RED, BLUE, CYAN | #f85149, #58a6ff, #39c5cf | errors and dialogs; the brand; the key bar |
SEL_BG, KEY_LABEL_BG | #13356a, #1c2733 | the selected row; the key bar labels |
Each panel has a colour taken from the gradient of its content (CPU green, memory cyan, network blue, disks orange, processes orange, GPU purple). Percentages are coloured with pct_color(), temperatures by grad(TEMP, (t − 30) ÷ 60).
5.6 · Colour depth
Most terminals can show 24-bit colour, but not all: the Linux console has 16 colours, and some terminals only 256. ngtop draws everything in truecolor and converts at the very end.
| Case | Mode |
|---|---|
configuration or --256 says 256 colours | 256 |
configuration says truecolor | truecolor |
COLORTERM contains truecolor or 24bit | truecolor |
TERM is linux | 16 colours |
TERM contains truecolor or direct | truecolor |
| anything else | 256 colours |
In the event loop, after view::draw() and before the buffer is sent, convert_buffer() visits every cell once and converts its foreground and background. In truecolor it does nothing. It is the only place where depth is considered, so no widget needs to know about it.
| Mode | Conversion |
|---|---|
| 256 | xterm256(r, g, b): the xterm palette is a 6 × 6 × 6 cube (levels 0, 95, 135, 175, 215, 255, indices 16 to 231) and a 24-step grey ramp (indices 232 to 255, levels 8, 18, … 238). The colour is matched to the nearest cube point, channel by channel, and to the nearest grey (average of the channels). The one with the smaller squared distance (Δr)² + (Δg)² + (Δb)² wins |
| 16 | ansi16(r, g, b, only8): the nearest of the 16 base colours by the same squared distance, using the usual xterm values (black 0,0,0 … white 255,255,255). Backgrounds use only the first eight, because the Linux console cannot show bright backgrounds |
| Other colours | Terminal-default colours (Color::Reset) and the 16 named colours are left alone |
5.7 · Layout
The interface has two layouts. LayoutMode::for_size() picks the compact one when the terminal is narrower than 100 columns or lower than 30 rows. It is recomputed at every resize. The other four sections adapt by hiding what does not fit; only the Overview is redrawn with a compact version.
| Quantity | Rule | Where |
|---|---|---|
| Left column of the Overview | width × left_width ÷ 100 (default 40 %), at least 60 and at most width − 40 | draw_big() |
| Height of the CPU panel | grid rows + header + gap + borders, at least 8, at most one third of the area (up to one half with more than 16 cores); never so tall that less than 10 rows remain | cpu_panel_height() |
| Grid of cores | Columns needed = cores ÷ available rows. First with a bar and a percentage in each cell; if they do not all fit, only label and percentage; if still not all, the rest is announced as “+N cores not shown” | core_layout() |
| Heights in Network & Disks | Every block has a minimum and a wanted height; minimums are given by priority (network, connections, interfaces, disks, filesystems) while they fit, then blocks grow toward the wanted height, and any spare rows go to the highest priority one | allocate() |
| GPU panels | As many columns as fit in 70 cells each; panel height is the one of the richest device; a history, engines and power row only if at least 14 rows are left below the device panels | gpu::draw() |
| Table columns | Columns appear only if the width allows: for example the User column from 50 cells, GPU% from 60 in Processes | table_columns() |
The compact Overview is a short header — CPU (meter, percentage, temperature, clock, and one sparkline cell per core), MEM, a GPU line if there is a GPU, NET and DSK — over a short process table (small_columns(): PID, User, CPU%, Mem, Command).
The sections
6.1 · Switching sections
The five sections are the five files in src/ui/sections/. Each has the same shape: a draw() function that fills the body rectangle, and, when it has keys of its own, an on_key() that gets the key before the general handler of App::on_key(). The order matters: when the dialog is open it takes every key; then the Services section; then the filter being typed; then the Network & Disks and Processes sections; then the keys common to all.
| Key | Everywhere |
|---|---|
| 1 … 5, Tab, Shift-Tab | Switch section |
| F1 | Help window (help_lines() in keys.rs); Esc, Enter, F1 or q closes it |
| + / - | Refresh interval: the next or the previous of 250, 500, 1000, 2000, 5000 ms |
| W | Save the interval and the section to the configuration file (other lines and comments are kept) |
| q, F10, Ctrl-C | Quit |
| Mouse | Click a tab, a row or a column header; the wheel scrolls |
6.2 · The Overview section
The first section shows the whole machine at a glance, and is the one the program starts in.
| Panel | Shows | From |
|---|---|---|
| cpu | A braille graph of total CPU use on the left. On the right: a meter with total percentage, temperature and average clock, and one meter per core (C0, C1 …). The model and the thread count are on the top border. When there are too many cores to fit, the bottom border says “+N cores not shown” | cpu.rs, sensors.rs |
| mem | Meters Used, Available, Cache, Free and Swap, with a memory graph beside them when there is room. A last line adds ZRAM (original → compressed and the ratio), zswap and Dirty if present | mem.rs |
| gpu | The selected GPU in brief, only if there is one and there is room (key d switches card) | gpu.rs |
| net | Rates of the chosen interface with a graph; its name, address and “(down)” on the border; n / N switch interface | net.rs |
| disks | One line per filesystem: usage bar and activity | disk.rs |
| processes | The process table: PID, Name, Command, User, Thr, Mem, GPU% and VRAM (with a GPU), CPU% — columns appear as the width allows | process.rs |
| Key | Action |
|---|---|
| Up Down PgUp PgDn Home End | Move the selection. It is kept on the same pid when the list is reordered |
| F6, >, < | Next / previous sort column (PID, Name, User, Mem, CPU, GPU, VRAM). Clicking a header does the same |
| i | Invert the sort direction |
| F5 | List or tree. The tree setting of the Overview is separate from that of Processes (default: list) |
| /, F3, F4 | Filter: type text; it matches the name, the command line, the user or the pid, ignoring case. Enter keeps the filter, Esc removes it |
| F9 | Send a signal to the selected process (7.1 “Signals: pidfd and start time”) |
| n / N | Next / previous network interface |
| d | Next GPU |
Compact layout. Under 100 columns or 30 rows, the Overview becomes a header of one-line meters (CPU with a sparkline of the cores, MEM, GPU, NET, DSK) above a table of PID, User, CPU%, Mem and Command. The bottom bar shortens to Help, Filter, Tree, Sort, Sig, Quit.
6.3 · The GPU section
The GPU section is nvtop inside ngtop: one panel per device, a history of the selected one, and the processes that use any GPU.
| Part | Shows |
|---|---|
| Device panels | One per card, side by side if there is room (70 cells each). A cell for each available quantity: usage meter, VRAM (and GTT / system memory), ENC / DEC / VID engines, temperature, power and its cap, clocks, fan, PCIe link (current / maximum). The title has the name. A sleeping card shows its state instead (4.8 “GPU: devices”) |
| GPU N history | Braille graphs of usage and VRAM, temperature and power of the selected card, 600 samples |
| engines · GPU N | Busy percentage of each engine (graphics, compute, copy, video decode and encode) as read from the clients' fdinfo |
| power | Present with room: power draw against the limit |
| GPU processes | Only the processes using a GPU: PID, User, DEV (which card), Type of work (Graphics, Compute, Video, Copy), GPU%, ENC%, DEC%, VRAM, CPU%, Mem, Name and Command, as the width allows |
| Key | Action |
|---|---|
| d | Select the next device; the history and engines follow |
| F6, /, F9 | Sort, filter, signal: as in the Overview |
With no GPU the section shows a panel saying “No GPU detected”. With an NVIDIA card or a card asleep, the device panel says what it can: ngtop never invents a figure for what it did not read.
6.4 · The Processes section
Processes is the process manager: a large table, and for the selected process a detail area with seven tabs.
| Part | Shows |
|---|---|
| Table | The panel “process tree” (or “process list”): PID, User, CPU%, Mem, GPU% (with a GPU), Name. The default is the tree (the other sections default to a list). The bottom border shows the number of processes and threads and the selected row; the top right shows the filter |
| Detail | A panel titled “NAME · PID N”, with the owner on the border and the tabs inside. The General tab shows: |
| Detail, General | Command, directory, executable, parent, start time, state, nice / priority / scheduling policy, CPU affinity and last core, cgroup, user, threads |
| Detail tabs | General, Threads, Files, Net, Env, Limits, cgroup. Only the open tab's data is read (4.4 “Processes”) |
| CPU, memory & I/O | A graph of the selected process's CPU (as a share of one core, with its scale on the border); and RSS, PSS, shared, swap, VRAM, bytes read and written and their rates |
| Key | Action |
|---|---|
| Space | Fold or unfold the branch under the selected process. Folded branches are forgotten when their process disappears |
| F5 | Tree or list |
| Left Right | Previous / next detail tab |
| [ ] | Scroll the detail up / down by 3 lines (the wheel too, over the detail) |
| F7 / F8 | Nice −1 / +1 for the main thread of the selected process (7.3 “Nice”) |
| F9 or k | Signal dialog |
| F3 / F4 / / | Search / filter |
App::jump_to_pid()).6.5 · The Network & Disks section
This section joins what a network monitor and a disk monitor would show on two screens.
| Panel | Shows |
|---|---|
| Interface | Received and sent rates of the chosen interface as two mirrored braille graphs, the peak of the session, totals since start, IPv4 / IPv6, link speed and duplex, gateway and DNS |
| interfaces | Every interface with state, address, rates; click one to select it |
| connections | The TCP and UDP sockets, with local and remote address, state, queues, pid and process name. The border shows the filter, LISTEN and the sort |
| One panel per disk | Name on the border; model, type (HDD, SSD, NVMe), size, temperature; read and write rates; IOPS; busy %; latency; queue |
| filesystems | Mount point, device, type, usage bar, available space, inode use; “not responding” for a stuck mount |
| Key | Action |
|---|---|
| i / I | Next / previous interface |
| c | Protocols: TCP+UDP, then TCP, then UDP |
| l | Show or hide LISTEN sockets (shown by default) |
| F6 | Sort the connections by state, proto, local, remote, pid, process, queue, in turn |
| z | Reset the session peaks and totals |
| Enter | Go to the process of the selected connection (the Processes section) |
| F9 | Signal dialog for that process |
| Up Down Home End | Move in the connections |
| PgUp PgDn | Scroll the disk panels |
The connection list is collected only while this section is visible (4.6 “Connections: inode to pid”). The height of the blocks is shared out by allocate() (5.7 “Layout”).
6.6 · The Services section
Services is a systemd manager: it lists the units, shows the detail and the journal of the selected one, and starts, stops, restarts, enables or disables it after asking.
| Part | Shows |
|---|---|
| List | Filter chips (All, Active, Failed, Enabled, Timers, Sockets, each with its count), then every unit: in the panel “systemd units”: a state symbol (● active, ◷ timer waiting, ◔ starting or stopping, ✖ failed, ○ inactive), name, description, state, whether it starts at boot, PID, memory and CPU%. Narrow terminals drop the description and the boot column first |
| Detail | Of the selected unit: description, states and since when, main pid and its command, memory with its peak, tasks, CPU time, restarts and policy, dependencies, the unit file; and below it the last journal lines |
| boot | Where boot time went: firmware, loader, kernel, initrd, services; and the slowest units |
| users & sessions | The login sessions, with the processes and memory of each user |
| journal · NAME | The journal of the selected unit in full screen |
| Key | Action |
|---|---|
| / | Search by name or description (Enter keeps it, Esc clears it) |
| f | Next filter chip |
| u | Switch between system units and the user's own units (session bus) |
| s, t, r | Start, stop, restart: each opens a confirmation (Cancel / Confirm) |
| e | Enable, or disable if it is already enabled (the same confirmation) |
| j | Journal full screen; Esc goes back |
| Up Down PgUp PgDn Home End | Move |
Layout. With 120 columns × 38 rows or more (a body of 120 × 36): list (58 %) and detail (42 %) on top, boot and sessions below. From 100 columns: list and detail side by side. Narrower: the list above and the detail below. Waiting. While a request is outstanding, the section polls the thread every 100 ms. If a request has waited 10 seconds, the status line says “systemd is not responding”. No systemd. If /run/systemd/system does not exist, the section shows a single panel: “Services unavailable: systemd not found”.
Rhythm of requests (Servizi::schedule()): the unit list every 2 s (every 0.6 s until it has been received twice); the detail and the journal only 200 ms after the selection has stopped moving (so scrolling quickly asks for nothing), the detail every 2 s and the journal every 5 s; the sessions every 10 s; boot times and the slowest units once. All requests are in 4.10 “Services, sessions, boot”.
Safety
7.1 · Signals: pidfd and start time
A signal sent to a pid is dangerous for one reason: pids are reused. Between the moment the user sees a process in the list and the moment they press Enter, that process may have ended and the kernel may have given its number to another one. A naive “kill the pid” would then kill an innocent. ngtop closes this window in three ways.
| Defence | How |
|---|---|
| Identify by pid and start time | A process is the pair (pid, starttime), the field 22 of /proc/PID/stat, in clock ticks since boot. Two processes with the same pid never have the same start time |
| Remember, then check | The pairs are remembered when the dialog opens; at the moment of sending, the root is checked first, each target again, and each once more inside send_signal() |
| Hold the process, not the number | Linux 5.3 and later give a pidfd: a descriptor that refers to one process for good. After pidfd_open(pid), the start time is read again; if it is the expected one, the descriptor is the process the user chose, and the signal goes through pidfd_send_signal, which cannot reach another process even if the pid is reused a microsecond later |
send_signal() is the only function that sends a signal. Its order is deliberate: open first, check after. If it checked and then opened, the pid could change in between; opened first, the descriptor and the check refer to the same process.
| Situation | What send_signal() does |
|---|---|
pidfd_open works, start time equals the expected one | pidfd_send_signal on the descriptor |
pidfd_open works, start time differs | Nothing is sent: “the pid now belongs to another process (reused)” |
pidfd_open fails with ESRCH | “the process no longer exists (ESRCH)” |
pidfd_open fails for another reason (old kernel, or the syscall is not allowed) | Checks the start time, then falls back to kill(). It is less strong, but the check precedes it immediately |
pidfd_send_signal fails with something other than ESRCH | Falls back to kill() for this pid, still right after the check |
| The kernel refuses (EPERM, EACCES) | The error is shown as text, e.g. “permission denied (EPERM)” |
No C library is involved: the calls are in rustix (pidfd_open, pidfd_send_signal, kill_process, setpriority_process), which makes the raw system calls.
7.2 · The signal dialog
The dialog (SignalDialog, key F9 or k) lets the user choose a signal and whether it also goes to the descendants.
| Signal | Name | Description in the dialog |
|---|---|---|
| 15 | SIGTERM | ask to quit (recommended) — first in the list, so Enter alone is the polite choice |
| 9 | SIGKILL | stop now, unsaved work lost |
| 19 | SIGSTOP | pause |
| 18 | SIGCONT | resume |
| 1 | SIGHUP | reload configuration |
| 2 | SIGINT | interrupt (like Ctrl-C) |
| 10 | SIGUSR1 | user signal 1 |
| 12 | SIGUSR2 | user signal 2 |
Up and Down choose the signal, Space toggles “whole subtree”, Enter sends, Esc cancels. These are the rules:
| Rule | Detail | Function |
|---|---|---|
| PID 1 is never touched | Not alone, not as part of a subtree. Pressing Enter on it sends nothing and says so | protected(), targets() |
| ngtop itself | Allowed alone, with the warning “you are signalling ngtop itself”; skipped when inside a subtree | protected() |
| Subtree of PID 1 or 2 | Forbidden: it would be the whole system. The box cannot be ticked | subtree_allowed() |
| Subtree: only still-valid nodes | The set is rebuilt at send time: a node is included if its start time is the remembered one; or, for a process that was not in the remembered tree (a new child), if its parent is included and it started no earlier than the parent. A node whose pid was reused is dropped together with all its descendants | verified_subtree() |
| The root first | If the root has changed, nothing at all is sent: “process N has exited, no signal sent” | send_dialog() |
| Second confirmation | For a subtree of more than 50 processes (BIG_SUBTREE), or containing processes of other users: Enter must be pressed twice. Changing the signal or the box cancels the first press | needs_confirm() |
| Visible warnings | Other users' processes, protected pids and the size of the subtree are listed in the dialog | warnings(), lines() |
The result appears for five seconds in the bottom line instead of the key bar: “SIGTERM sent to 1234 firefox”, “SIGTERM sent to 7 processes (subtree of …)”, “SIGKILL to 1234 x: permission denied (EPERM)”, or “…: 5 sent, 2 errors (first: …)”. If some targets have vanished the line adds “(N no longer exist)”.
7.3 · Nice
Keys F7 and F8 lower and raise the nice value of the selected process by one.
| Rule | Detail |
|---|---|
| Range | −20 to 19. At the limit: “nice of PID NAME already at the limit (N)” |
| Which thread | Only the main thread, as htop does: setpriority on a pid acts on the thread whose tid equals it. The message says “(main thread)” |
| Lowering needs root | Raising the value (a lower priority) is allowed to the owner. Lowering it fails with EACCES or EPERM for an ordinary user; the message says “root is needed to lower the nice value” |
| Identification | Unlike signals, nice acts on the pid selected in the list with no start-time check |
7.4 · Service actions
Starting and stopping a service is another action that cannot be undone by ngtop, so it has its own care.
| Care | How |
|---|---|
| Always ask | Every action opens a dialog with the unit, the scope (system or user), and “If needed, polkit will ask for the password.”. The default button is Cancel; Left / Right moves to Confirm; Esc or n cancels |
| Use the system's authority | ngtop never asks for a password and never runs sudo. The D-Bus call carries the flag AllowInteractiveAuth, so polkit may open its own agent. Without an agent, the answer is a refusal and the line says: “Permission denied: a polkit agent is needed, or run ngtop with sudo” |
| Time limit | The action's connection waits up to 120 seconds, as long as a person may need to type a password. The status line says “waiting for authorization…” meanwhile |
| One at a time | While an action is outstanding, the keys s t r e do nothing, and a second request that reaches the thread is refused: “another action is in progress” |
| Not blocking | The action runs in its own thread, so the screen stays alive while polkit waits |
| Read errors | Texts like AccessDenied, InteractiveAuthorizationRequired or “not authorized” become “denied”; everything else is shown as received (classify_error()) |
| Fresh state | After enable or disable, the cache of unit file states is aged so the next list shows the change |
What ngtop does not do: it never edits unit files, never masks, never runs a command through a shell. Its only two external programs, journalctl and systemd-analyze, are read-only and get a fixed list of arguments, with the unit name as a separate argument and never through a shell.
7.5 · GPUs that must not be woken
A discrete GPU in D3cold or runtime-suspended costs almost nothing; reading its files wakes it, which can take a second and a few watts. A monitor that does this every second keeps the card on forever. The rule is therefore strict.
| Rule | Detail |
|---|---|
| Metrics are not read | For a discrete card whose power/runtime_status is “suspended” or whose power_state is “D3cold”, no usage, memory, clock, sensor or link file is opened. The panel shows “suspended” or “D3cold”. Test: gpu_sospesa_non_viene_interrogata |
| Integrated GPUs are read anyway | Intel on PCI bus 0, and an AMD APU once recognised: they live in the processor's package, there is nothing to save. Their state is Idle. Test: integrata_con_runtime_pm_si_legge_comunque |
| Unknown AMD cards are treated as discrete | The first time, before the memory size says it is an APU, the stricter rule applies |
| Clients are still counted | The processes are scanned for DRM descriptors and their fdinfo files as usual: per-process counters do not come from the device's sysfs folder |
| NVIDIA is only detected | No read at all besides the identity |
7.6 · Terminal restore
A terminal program that leaves the terminal in raw mode on the alternate screen, with the cursor hidden and the mouse on, ruins the user's shell. ngtop gives the terminal back in every way it can end.
| How it ends | What happens |
|---|---|
| Normal quit (q, F10, Ctrl-C) | The loop ends and the guard is dropped |
Error in the loop (?) | The function returns; the guard is dropped on the way out |
| Panic | A hook installed first calls restore_terminal() before the panic message is printed, so the message is readable and not swallowed by the alternate screen |
| SIGTERM, SIGINT, SIGHUP | signal_hook sets a flag; the loop looks at it at least every 250 ms and ends in the normal way. A second signal while the flag is set ends the process at once with status 1, in case the loop is stuck |
| Closed terminal window | SIGHUP: the same as above |
| Not a terminal | If standard input or output is not a terminal, ngtop refuses to start with a clear message (exit 2) and suggests --dump |
TerminalGuard is a value whose Drop calls restore_terminal(): it leaves the alternate screen, turns off the mouse, leaves raw mode and shows the cursor. Every step ignores errors and the function can be called several times, so the guard, the panic hook and the signal path can all run it. Everything that can fail is done before the terminal is touched: the first sample, the time zone, the configuration file.
scripts/test-distro.sh runs ngtop under a pseudo-terminal for three seconds, sends it SIGTERM and checks that the output contains the sequences that leave the alternate screen (ESC[?1049l) and show the cursor (ESC[?25h), that nothing says “panicked”, and that the exit status is 0, 124 or 143 (ch. 10 “Testing”).Output to a closed pipe (for example ngtop --dump | head -1) is also safe: write_stdout() ignores write errors, so there is no panic for a broken pipe.
7.7 · Time zone without trust
To show the clock, ngtop must know the local time zone offset. The usual way is a C library function, which a static musl binary has in a poorer form, so src/ui/tz.rs does it in Rust. The data are untrusted — a file anyone may have written — so the parser is cautious.
| Step | Rule |
|---|---|
| Which zone | The TZ variable (a leading “:” is removed). “UTC”, “UTC0” and “GMT” are UTC. An absolute path is read as a TZif file. A name is looked up in /usr/share/zoneinfo, /usr/lib/zoneinfo and /usr/share/lib/zoneinfo, but never if it contains “..”. Otherwise it is read as a POSIX rule such as CET-1CEST,M3.5.0,M10.5.0/3. Without TZ: /etc/localtime |
| Failure | Any error leads to UTC. Never a panic |
| TZif header | The first four bytes must be “TZif”. The six counts are read (as 32-bit numbers) |
| Size check before any allocation | The expected size of the data is computed from the counts with checked arithmetic (checked_mul, checked_add) and must fit in the file; otherwise the file is rejected. A hostile file cannot make ngtop reserve gigabytes |
| Version 2 and 3 | The second block, with 64-bit times, is used when present, followed by the footer: a POSIX rule that says what to do after the last listed transition |
| Finding the offset | Binary search (partition_point) for the last transition not after the instant. Past the end the footer rule decides, which is how a zone keeps working in the years beyond the table |
| POSIX rule | Standard name, offset, optional daylight name, offset and the two switching rules Mm.w.d/time (month, week with 5 = last, weekday, time). The day is computed from the calendar (Hinnant's algorithm for days from civil date), including leap years |
Tests: nessun_panic_su_prefissi_e_file_troncati feeds the parser every prefix of a POSIX rule, and of a real zone file; tzif_inventato_e_rotto builds files with impossible counts; posix_semplici_e_non_validi tries malformed rules.
Performance
8.1 · Budget and measurements
A monitor that uses a lot of CPU gives the wrong picture of the machine it watches. ngtop's goal is to be invisible: about one percent of one core at the default 1-second refresh on a small machine, a few megabytes of memory, and a start that feels instant.
| Measure | Value | How it was measured |
|---|---|---|
| CPU at 1 s refresh | about 1 % | Intel N100, default interval. One cycle takes 8 to 11 ms (measured with ngtop --bench), once a second |
| Resident memory | about 5.6 MB | Same machine, as reported on the project page |
| Binary | about 3.5 MB | dist/ngtop, static, stripped |
| Start to first frame | 25 to 30 ms | ngtop --bench-start: cold start and the mean of 10 starts, at 182 × 52 |
| One cycle | 8 to 11 ms | ngtop --bench (below) |
What the benchmark does. ngtop --bench (hidden option, bench() in src/main.rs) sets a 182 × 52 test terminal, shows each section in turn, runs three warm-up cycles and then thirty measured ones, each with its three parts timed separately: refresh (all collectors), update (histories, process list) and draw (a whole frame into a buffer). Terminal output is not included: ratatui sends only changed cells, and that is cheap by comparison.
$ ngtop --bench
1 Overview refresh 7.42 + update 0.17 + draw 2.91 = 10.50 ms
2 GPU refresh 6.93 + update 0.17 + draw 2.08 = 9.19 ms
3 Processes refresh 6.66 + update 0.40 + draw 2.15 = 9.21 ms
4 Network & Disks refresh 8.02 + update 0.28 + draw 2.26 = 10.56 ms
5 Services refresh 6.67 + update 0.17 + draw 1.52 = 8.37 ms
(248 processes, 1 GPU(s))/proc is most of the cost. On a quiet N100 a cycle takes 7 to 8 ms. Run ngtop --bench before and after a change that could slow things down, on the same machine.8.2 · Caches and schedules
Two thirds or more of a cycle is refresh, and most of that is reading one small file per process. Drawing a frame is 2 to 3 ms. The design follows from this: read less, not faster.
| Data | Cache or schedule | Where |
|---|---|---|
| Owner of a process | Read from status only for a new process, then every 15th cycle (staggered by pid, so a few per cycle); a daemon can change user without exec | read_one() |
| Command line of a process | Read once; again only if the process was replaced (new start time or name). An empty one is retried | read_one() |
| File reads | One buffer and one path buffer are reused for every file of every process: no allocation per file | read_into() |
| Open GPU descriptors of a process | Scanned when a pid appears, then every 5th cycle (with descriptors) or every 20th (without), staggered by pid; never again if denied | GpuCollector::refresh() |
| Static GPU data | Name, ids, driver read once per card. The pci.ids database (large) is read only when a new card appears and freed at once | read_static(), discover() |
| Socket to process | Cache by inode, incremental; the walk of all fds stops when everything wanted is found; unreadable processes are remembered | scan_fds() |
| Connections | Collected only with the section visible, every second cycle | System::refresh_with_dt() |
| Routes, addresses, DNS, link speed | Every 5th cycle | net::refresh() |
| Filesystem sizes | Every 5th cycle, in threads, answers collected without waiting | FsMonitor |
| Sensor files | Found once (also for each disk); then only the value is read | find_cpu_temp_path(), find_disk_temp() |
| Disk model, type, size | Once per disk | read_disk_static() |
| Heavy process detail | Only the open tab. smaps_rollup (tens of ms on large processes) only on the General tab and every 5th cycle | DetailCollector::refresh() |
| Services | Unit file states cached 60 s; cgroup path kept while the unit lives; main pid asked again every 10 s or on a sub-state change; detail and journal asked 200 ms after the selection stops | dbus.rs, Servizi::schedule() |
| Cgroup memory and CPU | Read from files, one per unit, rather than one D-Bus call per row | CgroupSampler |
Why staggered? If every pid were rechecked in the same cycle (say every 15th), that cycle would be much slower than the others and the display would stutter. Adding the pid to the cycle counter spreads the work evenly. The same trick is used for the GPU descriptors.
8.3 · The interface side
The interface side is kept cheap too.
| Choice | Effect |
|---|---|
| Redraw only when needed | A frame is drawn after a key, a mouse event, a resize, a service answer or a tick. Between ticks an idle ngtop sleeps in event::poll, waking at most four times a second to look for signals |
| Diff on output | ratatui compares the new buffer with the previous one and sends only the changed cells; a steady screen costs few bytes |
| Fixed-size histories | Each series is a ring of 600 samples (History): memory does not grow with the run time. Histories of interfaces, disks and GPUs that disappear are dropped |
| Short intervals are explicit | 250 ms is the fastest the user may choose (key -); the cost is then about four times as high as at 1 s |
| No work for hidden sections | The Processes detail, the connection list and the Services thread are updated only when their section is visible |
| Release build | opt-level = 3, LTO, one codegen unit: the compiler sees the whole program and inlines across crates |
Known costs, for whoever wants to go further. App::update() clones the process vector from System on every cycle and sorts it; the tree and the filter are rebuilt each time. With thousands of processes this would be the first thing to optimise. And the whole refresh runs in the interface thread: a very slow /proc (a machine under heavy load) delays the next frame by the same amount.
The static build
9.1 · Why static
ngtop is meant to be copied to a server, a container or an old workstation and run there. A normal Linux executable depends on the machine's C library, and the version it was built against must not be newer than the one it finds. A static executable carries everything it needs: it asks the kernel for services, and nothing else.
| Requirement | How it is met |
|---|---|
| No dependency on the distribution's libc | Built for x86_64-unknown-linux-musl: the musl C library is linked into the file, not loaded at run time |
| No C code in the build | All dependencies are Rust crates that need no C compiler |
| Proof, not hope | build.sh fails if ldd finds a dynamic dependency |
| Tried on old and new systems | scripts/test-distro.sh runs the file on Ubuntu 24.04, Debian 12 and 10, Fedora 41, Arch, Alpine 3.20, openSUSE Tumbleweed and CentOS 7 |
$ ldd dist/ngtop
statically linked
$ bash build.sh
...
OK: dist/ngtop is static (3.5M)9.2 · Dependencies: Rust only
The seven direct dependencies are all Rust. Each does one job.
| Crate | Version | Used for | Where |
|---|---|---|---|
ratatui | 0.30 | Buffer, layout and test backend for the interface | src/ui/ |
crossterm | 0.29 | Raw mode, alternate screen, keys, mouse, terminal size | src/ui/mod.rs |
rustix | 1.1 | System calls without a C library: pidfd_open, pidfd_send_signal, setpriority, statvfs, geteuid, clock ticks, page size. On Linux x86_64 it issues the raw system calls itself | process.rs, disk.rs, processi.rs |
zbus | 5 | D-Bus in pure Rust, with the blocking API: systemd and login1 | services/dbus.rs |
serde_json | 1.0 | Reads journalctl -o json lines | services/mod.rs |
signal-hook | 0.3 | Sets flags on SIGTERM, SIGINT, SIGHUP without unsafe code in ngtop | src/ui/mod.rs |
unicode-width | 0.2 | Width of characters in terminal cells | widgets/mod.rs |
The full tree for the musl target has about 150 crates (cargo tree --target x86_64-unknown-linux-musl). The ordinary way for a crate to compile C is the cc crate in a build script; cargo tree -e normal,build -i cc --target x86_64-unknown-linux-musl finds nothing, which is the proof that none does. The crate libc is in the tree, but it only declares function signatures; with musl, the functions come from the musl library that the toolchain links in. The licences of all these crates are listed by scripts/third-party.py.
cargo tree -i cc and build.sh: if either shows C, reconsider (ch. 11 “Extending ngtop”).9.3 · musl and build.sh
The default Linux toolchain links against glibc, and a program built that way cannot be copied to Alpine or to an old CentOS. For x86_64-unknown-linux-musl Rust ships its own musl and links statically by default, so no C compiler or musl package is needed on the build machine: only the target, added once with rustup target add x86_64-unknown-linux-musl.
| Step of build.sh | Why |
|---|---|
set -euo pipefail | Stop at the first error, including inside pipelines |
cd "$(dirname "$0")" | Works from any folder |
export PATH="$HOME/.cargo/bin:$PATH" | Finds cargo even in a shell where rustup is not initialised |
cargo build --release --target x86_64-unknown-linux-musl | The static build, with LTO and strip (Cargo.toml) |
rm -f dist/ngtop | A running ngtop would give “text file busy” on overwrite |
cp target/…/ngtop dist/ngtop | The finished file is dist/ngtop |
ldd dist/ngtop | grep -qE "not a dynamic executable|statically linked" | The verification; on failure, the dependencies are printed and the exit status is 1 |
The result is about 3.5 MB: opt-level = 3, lto = "fat", codegen-units = 1 and strip = true (no debugging symbols).
9.4 · What static takes away
Static linking with musl removes some conveniences a dynamic program takes for granted. ngtop works around each of them in its own code.
| Missing | Consequence | What ngtop does |
|---|---|---|
| The name service switch (NSS) | A static musl program cannot ask sssd, LDAP or NIS for user names | Reads /etc/passwd itself (load_users()). A user known only to a directory service is shown by its number |
| Time zone from libc | No localtime configured by the distribution | Reads TZ and TZif files in Rust (7.7 “Time zone without trust”) |
| Host name from libc | — | Reads /proc/sys/kernel/hostname |
| Loading shared libraries | No dlopen of a driver's library | Everything comes from kernel files. This is the reason for the next section |
| Different libc behaviour | musl differs from glibc in details (locale, resolver) | ngtop uses no locale and does not resolve names |
9.5 · Why not NVML
Of the three GPU makers, AMD and Intel publish their counters in the kernel, in sysfs and in each process's fdinfo. NVIDIA's proprietary driver does not; its numbers come from NVML, the NVIDIA management library, a closed-source shared library installed with the driver. A program uses it by loading it at run time. A static musl executable has no dynamic loader, so it cannot load a library built for glibc.
| Choice | Consequence |
|---|---|
| Do not use NVML in version 1.0 | NVIDIA cards are detected (name, driver) and appear with the state “unsupported”; no figure is shown |
| Keep the single static file | The promise “copy it anywhere, it runs” is worth more than one maker's counters in this version |
| Open for the future | NVIDIA support through NVML is on the list of future work. How to reconcile it with a static executable is still open |
Testing
10.1 · Kinds of test
There are two kinds of test, and a few tools to look at the screen without a terminal. Nothing needs root, a GPU, systemd or a network: everything runs from fake trees and a fake services backend.
| Kind | Where | How many | What it checks |
|---|---|---|---|
| Unit tests | Inside each source file, in #[cfg(test)] mod tests | 135 test functions | One parser, one formula, one widget. They feed text to a parse_*() function and compare numbers |
| Integration tests | tests/*.rs | 42 test functions | The collectors over a fake tree, and the views drawn from them, through the public library |
| Distribution tests | scripts/test-distro.sh | 8 distributions × 4 cases | The finished static binary on real systems |
| File | Subject |
|---|---|
tests/collect.rs | CPU, memory, load, processes (names with parentheses, tree, a reused pid), network, disks, sensors, on the two snapshots |
tests/gpu.rs | AMD, Intel (i915, xe), integrated and discrete, suspended, NVIDIA, no GPU; the GPU views |
tests/retedischi.rs | Network, disks and connections: collectors, 'off when not visible', drawing at every size, keys |
tests/servizi.rs | Services with a fake backend: drawing, filters, search, an action with its confirmation, a refusal, no systemd, no blocking |
tests/ui.rs | The Overview large and compact; every section at 81 sizes; machines with 1 to 128 cores |
tests/cli.rs | The program as a process: it refuses to start without a terminal (exit 2); --help and --version work without one |
10.2 · Fixtures and two samples
A rate needs two samples, so the fixtures come in pairs: A and B, as if taken one second apart. A test loads A, refreshes with dt = 0, swaps the roots to B and refreshes with dt = 1.0. Then every number is exact and nothing depends on the real clock.
fn sistema() -> System {
let mut s = System::new(roots("snap_a"));
s.clock_ticks = 100;
s.page_size = 4096;
s.refresh_with_dt(0.0); // first sample: rates are zero
s.roots = roots("snap_b");
s.refresh_with_dt(1.0); // one second later
s
}Take the CPU. The cpu line of snapshot A is 1000 0 500 8000 500 0 0 0 0 0, of B 1100 0 550 8050 500 0 0 0 0 0. Totals: 10 000 and 10 200, so Δtotal = 200. Idle (idle + iowait): 8 500 and 8 550, so Δidle = 50. Usage = (200 − 50) × 100 ÷ 200 = 75 %, which is exactly what the test asserts. For a process, 80 ticks at 100 Hz in 1 s on 2 cores is 40 %.
Folder in tests/fixtures/ | Contents |
|---|---|
snap_a, snap_b | A reduced proc, sys and etc: the files ngtop reads, with up to seven processes (one named “a) b” to test the parser; one without permission on io; one owned by a user known only by number), two cores, a zram device, a mount table, network counters |
gpu/* | One tree per case: amd_a and amd_b (two samples), i915_a, i915_b, i915_idle, i915_arc_a and i915_arc_b (a discrete Intel), xe_a, xe_b, susp and susp_rpm (suspended: D3cold and runtime), nvidia, none |
rete_dischi/a, b | Interfaces, routes, connections with their sockets, disk counters and mounts |
cgroup/a, b | cgroup files for the Services CPU% and memory |
10.3 · Seeing the screen: snapshot, dump, sweep
Drawing is tested in two ways: by looking at its text, and by trying to break it.
| Tool | What it does |
|---|---|
ngtop --snapshot WxH [--section N] [--ansi] | Collects about 20 samples 100 ms apart; with --roots (fixtures) the graph histories are then replaced by about 300 synthetic samples around the last values (synthesize_history()), so the figures of the manuals have full graphs that are drawn, not measured. It draws one frame at the given size on a test backend and prints it, as text or, with --ansi, with truecolor escape codes. N is 1 to 5. This is how the screens in these manuals are made |
--roots DIR | Hidden option: read DIR/proc, DIR/sys and DIR/etc instead of the machine. With a fake tree, the screenshot contains no real names, addresses or processes. With it, the Services section of a snapshot uses the fake backend (demo units, no D-Bus) |
--doc-json | Hidden option: the keys and the command-line options as JSON, produced from the same tables and the same help text the program uses, so the key and option reference of the manuals cannot differ from the program |
--dump [--tree] | A text summary and no interface: CPU and cores, memory, swap, load, uptime, the 15 busiest processes, optionally as a tree. Useful over a bad link, in a bug report, and in CI |
--bench | Times a cycle per section (8.1 “Budget and measurements”) |
--bench-start | Times the start up to the first frame |
$ ngtop --snapshot 80x24 --section 2
$ ngtop --roots tests/fixtures/gpu/amd_a --snapshot 120x36 --section 2 --ansiThe sweep. tutte_le_misure_e_sezioni_senza_panic draws all five sections at every combination of widths 1, 5, 12, 40, 80, 99, 100, 101, 182 and heights 1, 3, 5, 12, 24, 29, 30, 31, 52, and converts each frame to ANSI. The values straddle the thresholds (99 and 100, 29 and 30) and include terminals so small nothing fits. The same is done in other files for narrower sets of sizes: the GPU and Overview views over six GPU fixtures (tasto_d_cambia_dispositivo_e_sweep), Network & Disks (rendering_a_ogni_misura), Services (rendering_a_quattro_misure_senza_panic) and every Processes tab (disegno_senza_panic_alle_misure_previste). molti_core_si_adattano builds machines with 1, 4, 24, 64 and 128 cores and checks that the frame has exactly the requested number of lines and that the last core is present — or, at 128 cores in a small window, that “not shown” is.
10.4 · Tests that guard the promises
Most of ngtop's safety is a promise about when something happens. These tests make the promise checkable.
| Promise | Test |
|---|---|
| The interface never waits for the services thread | l_interfaccia_non_attende_il_backend and tenendo_premuto_giu_non_partono_richieste_a_raffica in tests/servizi.rs: a backend that sleeps; a held key; the number of requests is counted |
| Only one service action at a time | un_azione_lenta_non_blocca_la_coda_e_ce_ne_e_una_sola (worker.rs) |
| A reused pid never receives a signal | pid_riusato_non_riceve_nulla, radice_riusata_i_figli_del_nuovo_non_ricevono, nodo_intermedio_riusato_salta_il_ramo (processi.rs) |
| PID 1 and ngtop itself | pid_uno_e_se_stessi_protetti, sottoalbero_di_init_vietato |
| A large subtree needs a second Enter | sottoalbero_grande_chiede_seconda_conferma |
| A sleeping GPU is not read | gpu_sospesa_non_viene_interrogata, integrata_con_runtime_pm_si_legge_comunque (tests/gpu.rs) |
| Connections are collected only when shown | la_raccolta_connessioni_e_spenta_se_la_sezione_non_e_visibile (tests/retedischi.rs) |
| No terminal, no interface | senza_terminale_non_apre_la_tui (tests/cli.rs) |
| Hostile time zone data | nessun_panic_su_prefissi_e_file_troncati, tzif_inventato_e_rotto (tz.rs) |
| Stuck mounts do not stop the others | statvfs_in_thread_non_blocca_e_segnala_non_risponde (disk.rs): a fake probe that blocks |
| A real signal works | segnale_reale_a_un_figlio starts a child process, signals it through the whole path and checks it died |
10.5 · Distributions
scripts/test-distro.sh runs the finished binary dist/ngtop in containers, with rootless podman, on the images below. It needs only podman and the binary; the containers have no network (--network=none).
| Image | Why it is in the list |
|---|---|
| Ubuntu 24.04, Debian 12, Fedora 41 | Current mainstream distributions |
| Arch Linux, openSUSE Tumbleweed | Rolling releases: a new kernel and new files |
| Alpine 3.20 | musl-based, no systemd, tiny |
| Debian 10, CentOS 7 | Old kernels and old libraries: the strongest test of “static” |
| Case | Command in the container | Pass if |
|---|---|---|
dump | ngtop --dump | exit 0 and output not empty |
snap120 | ngtop --snapshot 120x40 | the same |
snap80gpu | ngtop --snapshot 80x24 --section 2 | the same |
| interactive | ngtop under a pseudo-terminal (script), TERM=xterm-256color, 120 × 40, for 3 s, then SIGTERM | the output has the sequences that leave the alternate screen and show the cursor; no “panicked”; exit status 0, 124 or 143; if script is missing in the image: n/a |
The script prints a table with OK or ERROR for each image and keeps a log per case in the folder given as first argument (default /tmp). Other images can be passed as further arguments.
build.sh first: the test uses the binary in dist/, not a debug build.10.6 · What to run
| Command | When |
|---|---|
cargo test | Always, before a change is considered finished: unit and integration tests |
cargo clippy --all-targets -- -D warnings | The linter; warnings are errors |
cargo fmt | Formatting |
build.sh then scripts/test-distro.sh | Before a release, or after a change that touches system calls, files or the terminal |
ngtop --bench | After a change in a collector or in drawing (ch. 8 “Performance”) |
python3 docs/sources/build.py --check | After a change in code that the manuals describe: it verifies the cited functions and files and that the numbers are up to date |
Extending ngtop
11.1 · Add a collector
This tutorial adds a small collector from start to finish: the number of file handles in use on the whole system, from /proc/sys/fs/file-nr. The pattern is the one of every collector in src/collect/.
- Write the module
src/collect/filenr.rs. A data struct; aparse_*()that takes text and never panics; aread_*()that takes a path and usesread_text(). Put the test in the same file. Start the file with the two licence lines used everywhere (ch. 12 “Conventions”).src/collect/filenr.rs//! Open file handles of the whole system, from /proc/sys/fs/file-nr. use std::path::Path; use super::read_text; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct FileNr { /// Handles in use. pub used: u64, /// System-wide limit. pub max: u64, } /// "allocated unused max": in use = allocated - unused. pub fn parse_file_nr(text: &str) -> FileNr { let n: Vec<u64> = text .split_whitespace() .map(|x| x.parse().unwrap_or(0)) .collect(); let get = |i: usize| n.get(i).copied().unwrap_or(0); FileNr { used: get(0).saturating_sub(get(1)), max: get(2), } } pub fn read_file_nr(path: &Path) -> FileNr { read_text(path).map(|t| parse_file_nr(&t)).unwrap_or_default() } #[cfg(test)] mod tests { use super::*; #[test] fn file_nr() { let f = parse_file_nr("3456\t0\t9223372036854775807\n"); assert_eq!((f.used, f.max), (3456, 9223372036854775807)); assert_eq!(parse_file_nr(""), FileNr::default()); } } - Connect it to System. Four lines in
src/collect/mod.rs. Always build the path fromself.roots, never write/procin the collector.src/collect/mod.rs// src/collect/mod.rs pub mod filenr; // 1. declare the module pub struct System { // ... pub files: filenr::FileNr, // 2. the field } // in System::new(): files: filenr::FileNr::default(), // 3. its initial value // in System::refresh_with_dt(): self.files = filenr::read_file_nr( // 4. read it, through the roots &self.roots.proc.join("sys/fs/file-nr"), ); - Decide how often. One small file can be read every cycle. A costly one should be read every 5th cycle, like routes (
refresh_count.is_multiple_of(5)), or only when a section is visible, like connections (acollect_*flag, set inevent_loop(),snapshot()andbench()). See 8.2 “Caches and schedules”. - Add the file to the fixtures. Put
proc/sys/fs/file-nrintests/fixtures/snap_a/andsnap_b/, with different numbers if it is a rate, then a test intests/collect.rsthat asserts the value after the two samples (10.2 “Fixtures and two samples”). - Show it. The quickest place is
dump()insrc/main.rs: one moreoutln!. To show it on screen, see the next section. - Run
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt, andngtop --dump.
| Rule | Why |
|---|---|
| Parse text, read paths | A parser tested with strings needs no file; the reader is two lines |
| Missing, empty, strange: a default | Never unwrap() or expect() on data from the system; use unwrap_or, ? on an Option, saturating_sub, checked_* |
| A rate needs a previous sample | Keep it in System (like prev_cpu) and use the dt that refresh_with_dt() is given, not the clock |
| Keep what is slow out of the interface thread | If a read can block (a network file system, a device that wakes up), do it in a thread, as FsMonitor does |
| Never wake hardware | If the data comes from a device that sleeps, learn from 7.5 “GPUs that must not be woken” first |
11.2 · Add a panel or a section
A new panel inside a section, or a new section, is only drawing. The data is already in System and App.
- Draw with a Panel.
Panel::new(title, colour).right(text).render(area, buf)returns the inner rectangle. Inside, write withput()andputn(), never with ratatui's own functions (5.2 “Safe writing and widths”). UseMeterfor bars andBrailleGraphfor histories; take the colour from a gradient intheme.rs. - Check the size first. Return at once if
inner.widthorinner.heightis too small for your content, as the existing panels do (if inner.width < 20 || inner.height < 3 { return; }). - Place it in the layout of the section's
draw()(aLayout::verticalorhorizontalof the existing code). Decide what gives way on a small terminal; hide the panel rather than squeeze it. - If it needs history, add a
HistorytoAppand push to it inApp::update(). - If it has keys, handle them in the section's
on_key(), and add them to the key table insrc/ui/keys.rs: the bottom bar, and the lines of the F1 help. - Test that the section still draws at every size: the sweep in
tests/ui.rscovers all sections automatically.
A whole new section is the same work plus a list of places that know there are five of them. Search the source for Section::ALL, Section::Servizi and the text “1-5”:
| Where | What to change |
|---|---|
src/ui/sections/NEW.rs, sections/mod.rs | The file with draw() and, if needed, its state struct; a pub mod line |
Section in src/ui/app.rs | A variant; add it to ALL (the array length changes) and to title() |
view::draw() in src/ui/view.rs | A branch in the match, and the choice of key bar |
src/ui/keys.rs | A KEYS_… table; add it to section_bars() (its array length) and one entry to SECTION_HELP, in the same order as Section::ALL; “1-5” becomes “1-6” everywhere |
App::on_key() | The digit pattern '1'..='5' that selects a section |
src/config.rs | The range check (1..=5) of --section, the text of USAGE, and the tests that assume the last section |
App fields and App::new() | The state of the new section |
event_loop(), snapshot(), bench() | If the section needs a collect_* flag in System, set it in the three places |
README.md and the manuals | The list of sections and keys |
src/ui/keys.rs, used by --doc-json). Change keys there and only there.11.3 · Where things go
A short guide to deciding where something belongs.
| I want to… | Where |
|---|---|
| Read a new kernel file | A collector in src/collect/; 4.1 “How the collectors read” |
| Show a number differently | The section's drawing function; formatting helpers are in src/ui/fmt.rs |
| Add a colour or gradient | src/ui/theme.rs. Check the result on 256 and 16 colours (5.6 “Colour depth”) |
| Change a refresh rhythm | The condition on refresh_count in the collector or in System::refresh_with_dt(); update 8.2 “Caches and schedules” |
| Add an option or a setting | src/config.rs: Cli, Config, parse_full(), merge(), USAGE; it appears in the manuals through --doc-json |
| Support another GPU driver | src/collect/gpu.rs: a branch in read_dynamic(), and its engines' names in engine_kind() |
| Support another init system than systemd | A new implementation of the Backend trait in src/collect/services/ (the fake one in fake.rs shows the minimum) and a choice in Servizi::start() |
| Add a new dependency | Only a pure Rust crate (9.2 “Dependencies: Rust only”) |
Conventions
12.1 · Language, names and headers
The rules below are the ones the code already follows. Where a rule can be checked by a tool, the tool is named.
| Rule | Detail | Checked by |
|---|---|---|
| Text on screen is English | Panel titles, key labels, dialogs, status lines, messages, --help | reading; the manuals check their own text |
| Comments are short and in Italian | One or two lines above the function or the odd line, saying why, not what | — |
| Documentation is English | These manuals, README.md, decisions-and-history.md | build.py --check flags Italian sentences in the manuals |
| Names | Types, functions and variables are English; test names are Italian sentences with underscores; the modules and variants of the sections keep their Italian names (see the glossary) | — |
| Licence header | Every source file starts with two lines: // Copyright (c) 2026 Nicola Fiorillo and // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0. In scripts they follow the shebang line | reading |
| Formatting | cargo fmt, default rustfmt settings | cargo fmt --check |
| Lints | No clippy warning; the few #[allow(...)] are local and explained | cargo clippy --all-targets -- -D warnings |
// Copyright (c) 2026 Nicola Fiorillo
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
//! Memoria e swap da /proc/meminfo (valori in byte).12.2 · Rules for robust code
A monitor runs for weeks beside everything else on a machine. It must not be the thing that crashes. These rules are applied everywhere in src/.
| Rule | In practice |
|---|---|
| No panic on system data | Outside tests, the sources contain no unwrap(), no expect(…), no panic!, no unreachable!, no todo! and no unsafe. (Tests may use them.) A grep of src/ for them, ignoring test modules, should find nothing |
| Missing or unreadable is not an error | A file that is missing, or a field that cannot be parsed, gives None, a default or a dash on screen. Permission denied is the same: the value is “—” |
| Arithmetic cannot overflow or go negative | saturating_sub for counters, saturating_mul for sizes, checked_* in the time zone parser, u32 for products of screen sizes, and clamp for every shown percentage |
| Division is guarded | A rate is computed only when dt > 0; a ratio only when the denominator is non-zero; max(1) on core counts and tick rates |
| Draw inside the buffer | Only put() and putn() write cells; they ignore anything outside the buffer |
| Paths from the roots | Collectors take their paths from Roots (the literals are the defaults in Roots::default()). The Services code has two parameters of its own, run_dir and cgroup_root. Still literal: the zoneinfo folders, and one read of /proc/PID/comm in the services thread |
| Bound the data | Lists from the system are capped (200 open files, 500 environment variables, 50 journal lines), and the configuration file is read only if it is a small regular file |
| Never block the interface thread | Slow or possibly blocking work goes to a thread with a channel (3.4 “Threads”) |
| Do not run anything through a shell | External programs get a fixed list of arguments |
| Pure Rust dependencies | 9.2 “Dependencies: Rust only” |
12.3 · Habits of the code base
Habits that keep the code uniform.
| Habit | Detail |
|---|---|
| Parse and read apart | A function that takes text and returns a value, and one that reads the file; every parser has a unit test with a malformed input |
| Tests next to the code | A #[cfg(test)] mod tests at the end of the file; integration tests only for what needs a fake tree or a whole frame |
| Tests are deterministic | No real clock (refresh_with_dt), no real network, no real systemd (FakeBackend), no dependence on the machine's files (only tests/fixtures/, and /usr/share/zoneinfo with a fallback) |
| Replaceable edges | What is dangerous or slow is behind a function pointer or a trait so a test can substitute it: KillFn, StartFn, FsProbe, Backend |
| One source for each fact | Keys: keys.rs. Gradients: theme.rs. Options: USAGE. The manuals read them |
| Hidden diagnostics | --roots, --bench, --bench-start, --doc-json exist for developers and are not in --help |
| Simple state | State is plain structs with public fields and functions; no global mutable state except the temperature unit (a single atomic in fmt.rs) |
cargo fmt, cargo clippy --all-targets -- -D warnings, cargo testGlossary
13.1 · The project's own words
Words ngtop uses as terms, in the code and in this manual, with the section that explains them. Several source files keep their Italian names; the first terms tell which is which.
- Collector
- A function or module that reads kernel files and computes numbers; it knows nothing about the screen (
src/collect/). See 3.1 “Collect and draw”. - Roots
- The three folders (
proc,sys,etc) every collector reads from. Real on the machine, fake in tests. See 4.1 “How the collectors read”. - System
- The structure that holds the latest data of the whole machine and the previous samples for rates (
src/collect/mod.rs). See 3.2 “One refresh, step by step”. - App
- The state of the interface: histories, selection, filter, dialog, state of each section (
src/ui/app.rs). See 3.5 “State and drawing”. - Section
- One of the five main screens, selected with
1to5: Overview, GPU, Processes, Network & Disks, Services. In the code, the modules and variants have Italian names:panoramica/Panoramicais the Overview,processi/Processiis Processes,retedischi/ReteDischiis Network & Disks,servizi/Serviziis Services;gpuis the same in both languages. See ch. 6 “The sections”. - Large layout, compact layout
- The two forms of the Overview: compact under 100 columns or 30 rows. See 5.7 “Layout”.
- Tick
- One cycle of the event loop that refreshes the data and redraws; its length is the refresh interval (250 ms to 5 s). See 3.3 “The event loop”.
- dt
- The real time, in seconds, between two refreshes. A rate is a difference of counters divided by
dt; it is zero for the first sample. See 3.2 “One refresh, step by step”. - Frame, buffer
- One whole screen as a grid of cells (ratatui
Buffer), drawn from scratch each time and sent to the terminal as the cells that changed. See 5.1 “ratatui and the frame”. - Widget
- A small struct that draws one kind of element into a buffer (
Panel,Meter…). See 5.3 “The widgets”. - Panel
- A bordered rectangle with a title on its border; the unit of every section. See 5.3 “The widgets”.
- Braille cell
- One character U+2800 to U+28FF: a 2 × 4 grid of dots, used to draw graphs at eight times the cell resolution. See 5.4 “The braille graph”.
- Gradient
- A list of colour stops between which the program interpolates; every bar, graph and percentage takes its colour from one. See 5.5 “Theme and gradients”.
- Truecolor, 256, 16
- The three colour depths of a terminal; ngtop draws in truecolor and converts the frame at the end. See 5.6 “Colour depth”.
- Alternate screen
- A second terminal screen that a full-screen program draws on and then leaves, so the shell's text comes back intact. See 7.6 “Terminal restore”.
- Raw mode
- Terminal mode in which keys reach the program without being edited or echoed. See 7.6 “Terminal restore”.
- Fixture
- A fake
/proc,/sys,/etctree intests/fixtures/used by tests and by documentation screenshots. See 10.2 “Fixtures and two samples”. - Snapshot
- One frame drawn on a test backend and printed as text or ANSI:
ngtop --snapshot WxH. See 10.3 “Seeing the screen: snapshot, dump, sweep”. - Sweep
- A test that draws every section at many sizes, including absurd ones, to prove there is no panic. See 10.3 “Seeing the screen: snapshot, dump, sweep”.
- Backend (services)
- The trait through which the Services section talks to systemd. The real one uses D-Bus; the fake one gives demo data. See 3.4 “Threads”, 4.10 “Services, sessions, boot”.
- Worker thread
- The thread that executes the slow requests of the Services section while the interface keeps running. See 3.4 “Threads”.
13.2 · Linux and system terms
Terms of the system that ngtop reads, and what they mean for it.
- amdgpu, i915, xe
- The Linux kernel drivers of AMD, Intel (older) and Intel (newer) graphics. They publish counters in sysfs and in
fdinfo. See 4.8 “GPU: devices”. - cgroup
- The kernel's grouping of processes with its accounting. systemd puts each service in one; ngtop reads memory and CPU of a service from its cgroup files. See 4.10 “Services, sessions, boot”.
- Clock tick,
hz - The unit of CPU times in
/proc: usually 100 per second. See 4.2 “CPU, temperature”, 4.4 “Processes”. - D3cold
- The deepest power state of a PCI device. A GPU in D3cold is not read. See 7.5 “GPUs that must not be woken”.
- DRM client
- One user of a GPU, with its own counters, identified on a card by its client id. A process can show it, and so can its children. See 4.9 “GPU: processes and the fdinfo files”.
- D-Bus, polkit
- The message bus of Linux and the service that decides if an action may be done, asking for a password if necessary. See 7.4 “Service actions”.
- Engine
- A part of a GPU that works on one kind of task: graphics, compute, copy, video decode or encode. See 4.9 “GPU: processes and the fdinfo files”.
- fdinfo
- The file
/proc/[pid]/fdinfo/N, with the counters of a DRM client for one open GPU descriptor. See 4.9 “GPU: processes and the fdinfo files”. - hwmon, thermal zone
- The kernel's sensor interfaces under
/sys/class. ngtop reads temperatures, power and fans from them. See 4.2 “CPU, temperature”. - Inode (socket)
- The number in
/proc/net/tcpthat identifies a socket; the same number appears insocket:[N]in a process's file descriptors. See 4.6 “Connections: inode to pid”. - IOPS, latency, queue
- Operations per second, average time per operation, and requests in flight, of a disk. See 4.7 “Disks and filesystems”.
- musl
- A small C library that can be linked into an executable completely; with it ngtop is one static file. See 9.3 “musl and build.sh”.
- NSS
- The name service switch of the C library, which lets programs ask directories (LDAP, sssd) for user names. A static musl program has none. See 9.4 “What static takes away”.
- NVML
- NVIDIA's management library; shared and closed. The reason NVIDIA cards are only detected. See 9.5 “Why not NVML”.
- pidfd
- A file descriptor that refers to one process for ever, immune to reuse of the pid number. See 7.1 “Signals: pidfd and start time”.
- Runtime PM
- Power management that suspends a device when idle. A discrete GPU suspended this way is not woken. See 7.5 “GPUs that must not be woken”.
- Start time (
starttime) - The moment a process started, in clock ticks since boot; with the pid, the identity of a process. See 7.1 “Signals: pidfd and start time”.
- statvfs
- The call that gives the size and free space of a filesystem; on a stuck network mount it can block, so it runs in a thread. See 4.7 “Disks and filesystems”.
- TZif
- The binary format of time zone files in
/usr/share/zoneinfo, read by ngtop's own parser. See 7.7 “Time zone without trust”. - zswap, ZRAM
- Two ways of compressing memory instead of swapping it out to disk; the Overview shows the saving. See 4.3 “Memory, swap, load”.