Terminal system monitor in Gleam — htop × btop
1

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: Gleam system monitor (htop × btop)

Terminal process monitor with btop-style meters/sparklines and an
htop-style process table, built with etch over Linux /proc.

author
nandi
date (Jul 26, 2026, 12:14 PM -0700) commit 7dc50b0f
+1918
+17
.gitignore
··· 1 + # Gleam / Erlang 2 + /build 3 + *.beam 4 + erl_crash.dump 5 + .gleam 6 + 7 + # Nix 8 + /result 9 + /result-* 10 + .direnv 11 + 12 + # Editor / OS 13 + .idea 14 + .vscode 15 + *~ 16 + *.swp 17 + .DS_Store
+51
README.md
··· 1 + # monitor 2 + 3 + A terminal system monitor written in **Gleam** — sits between **htop** and **btop**. 4 + 5 + | From htop | From btop | 6 + |-----------|-----------| 7 + | Dense process table (PID, USER, PRI, NI, VIRT, RES, S, CPU%, MEM%, TIME+, Command) | Color meters & sparklines for CPU / memory / swap | 8 + | Sort by column, filter (`/`), kill (`k`) | Per-core CPU bars | 9 + | Keyboard-first navigation | Clean alternate-screen TUI | 10 + 11 + ## Requirements 12 + 13 + - Linux (`/proc`) 14 + - [Gleam](https://gleam.run) + Erlang/OTP 15 + - A modern terminal (truecolor optional, 256-color fine) 16 + 17 + ## Run 18 + 19 + ```sh 20 + # if Erlang isn't on PATH (NixOS): 21 + nix develop # or: nix-shell -p gleam beamPackages.erlang 22 + gleam run 23 + ``` 24 + 25 + ## Keys 26 + 27 + | Key | Action | 28 + |-----|--------| 29 + | `↑` / `u` · `↓` / `j` | Move selection (htop-style: `k` is kill, not up) | 30 + | `PgUp` / `PgDn` | Page | 31 + | `g` / `G` | Top / bottom | 32 + | `c` `m` `p` `t` `n` | Sort by CPU / MEM / PID / TIME / name (press again to reverse) | 33 + | `r` | Reverse sort | 34 + | `/` | Filter (substring on command, user, pid) | 35 + | `k` / `K` | Kill selected process (SIGTERM, confirms) | 36 + | `Space` | Force refresh | 37 + | `h` / `?` | Help | 38 + | `q` / `Esc` | Quit | 39 + 40 + ## Architecture 41 + 42 + ``` 43 + src/ 44 + monitor.gleam entrypoint 45 + app.gleam event loop, keys, refresh 46 + ui.gleam layout & rendering (etch) 47 + sys.gleam /proc collectors 48 + format.gleam bars, sizes, sparklines 49 + ``` 50 + 51 + Built with [etch](https://hex.pm/packages/etch) for the TUI backend.
+26
flake.nix
··· 1 + { 2 + description = "monitor — Gleam system monitor (htop × btop)"; 3 + 4 + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 5 + 6 + outputs = 7 + { self, nixpkgs }: 8 + let 9 + systems = [ 10 + "x86_64-linux" 11 + "aarch64-linux" 12 + ]; 13 + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); 14 + in 15 + { 16 + devShells = forAllSystems (pkgs: { 17 + default = pkgs.mkShell { 18 + packages = [ 19 + pkgs.gleam 20 + pkgs.beamPackages.erlang 21 + pkgs.rebar3 22 + ]; 23 + }; 24 + }); 25 + }; 26 + }
+15
gleam.toml
··· 1 + name = "monitor" 2 + version = "1.0.0" 3 + description = "Terminal system monitor in Gleam — between htop and btop" 4 + licences = ["Apache-2.0"] 5 + 6 + [dependencies] 7 + gleam_stdlib = ">= 1.0.0 and < 2.0.0" 8 + etch = ">= 1.4.0 and < 2.0.0" 9 + etch_erlang = ">= 1.1.0 and < 2.0.0" 10 + simplifile = ">= 2.6.0 and < 3.0.0" 11 + gleam_erlang = ">= 1.3.0 and < 2.0.0" 12 + filepath = ">= 1.1.2 and < 2.0.0" 13 + 14 + [dev_dependencies] 15 + gleeunit = ">= 1.0.0 and < 2.0.0"
+26
manifest.toml
··· 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 8 + 9 + packages = [ 10 + { name = "etch", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "etch", source = "hex", outer_checksum = "A4B4458EE1E7D22753538C03F0D64C930309863365D2D26570E2D2C6B7CFC5EA" }, 11 + { name = "etch_erlang", version = "1.1.0", build_tools = ["gleam"], requirements = ["etch", "gleam_erlang", "gleam_stdlib"], otp_app = "etch_erlang", source = "hex", outer_checksum = "CDD5BCBF680A97AF979EF1FD18F4E39E64DA2D7938422DEF938A69F26B38BF32" }, 12 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 13 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 14 + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 15 + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 16 + { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, 17 + ] 18 + 19 + [requirements] 20 + etch = { version = ">= 1.4.0 and < 2.0.0" } 21 + etch_erlang = { version = ">= 1.1.0 and < 2.0.0" } 22 + filepath = { version = ">= 1.1.2 and < 2.0.0" } 23 + gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } 24 + gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 25 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 26 + simplifile = { version = ">= 2.6.0 and < 3.0.0" }
+312
src/app.gleam
··· 1 + //// Main application loop: sample /proc, render, handle keys. 2 + 3 + import etch/command 4 + import etch/erlang/input 5 + import etch/erlang/tty 6 + import etch/event.{ 7 + type Event, type KeyCode, Backspace, Char, DownArrow, End, Enter, Esc, Home, 8 + Key, PageDown, PageUp, UpArrow, 9 + } 10 + import etch/stdout 11 + import etch/terminal 12 + import gleam/dict 13 + import gleam/erlang/process 14 + import gleam/int 15 + import gleam/list 16 + import gleam/option.{None, Some} 17 + import gleam/string 18 + import procfs.{type Snapshot} 19 + import ui.{ 20 + type SortKey, type ViewState, ConfirmKill, Filter, Help, Normal, SortCommand, 21 + SortCpu, SortMem, SortPid, SortTime, 22 + } 23 + 24 + const refresh_ms = 1000 25 + 26 + @external(erlang, "erlang", "halt") 27 + fn halt(code: Int) -> Nil 28 + 29 + @external(erlang, "os", "cmd") 30 + fn os_cmd(command: String) -> String 31 + 32 + pub fn run() -> Nil { 33 + let #(cols, rows) = window_size() 34 + let assert Ok(_) = tty.enter_raw() 35 + stdout.execute([ 36 + command.EnterAlternateScreen, 37 + command.HideCursor, 38 + command.Clear(terminal.All), 39 + command.DisableLineWrap, 40 + command.SetTitle("monitor"), 41 + ]) 42 + 43 + // Seed samples so first painted frame has real CPU % 44 + let prev_cores = dict.new() 45 + let prev_ticks = dict.new() 46 + let snap0 = procfs.sample_with_core_history(0, 0, prev_cores, prev_ticks) 47 + process.sleep(200) 48 + let snap = 49 + procfs.sample_with_core_history( 50 + snap0.prev_cpu_total, 51 + snap0.prev_cpu_idle, 52 + procfs.core_history_from(snap0), 53 + snap0.prev_proc_ticks, 54 + ) 55 + 56 + let view = 57 + ui.initial_view(cols, rows) 58 + |> ui.push_history(snap) 59 + 60 + loop(snap, view) 61 + } 62 + 63 + fn loop(snap: Snapshot, view: ViewState) -> Nil { 64 + let #(cols, rows) = window_size() 65 + let view = case cols != view.cols || rows != view.rows { 66 + True -> ui.ViewState(..view, cols: cols, rows: rows) 67 + False -> view 68 + } 69 + 70 + stdout.execute(ui.render(snap, view)) 71 + 72 + let #(snap2, view2, quit) = handle_input_window(snap, view, refresh_ms) 73 + case quit { 74 + True -> cleanup() 75 + False -> { 76 + let view2 = ui.push_history(view2, snap2) 77 + loop(snap2, view2) 78 + } 79 + } 80 + } 81 + 82 + /// Poll for keys up to `budget_ms`, then refresh system stats. 83 + fn handle_input_window( 84 + snap: Snapshot, 85 + view: ViewState, 86 + budget_ms: Int, 87 + ) -> #(Snapshot, ViewState, Bool) { 88 + let step = 50 89 + handle_input_window_go(snap, view, budget_ms, step) 90 + } 91 + 92 + fn handle_input_window_go( 93 + snap: Snapshot, 94 + view: ViewState, 95 + remaining: Int, 96 + step: Int, 97 + ) -> #(Snapshot, ViewState, Bool) { 98 + case remaining <= 0 { 99 + True -> { 100 + let snap2 = refresh(snap) 101 + #(snap2, view, False) 102 + } 103 + False -> { 104 + case input.poll(0) { 105 + Some(Ok(ev)) -> 106 + case apply_event(snap, view, ev) { 107 + #(snap2, view2, True) -> #(snap2, view2, True) 108 + #(snap2, view2, False) -> { 109 + // Immediate redraw on keypress for snappy feel 110 + stdout.execute(ui.render(snap2, view2)) 111 + handle_input_window_go(snap2, view2, remaining - step, step) 112 + } 113 + } 114 + Some(Error(_)) -> { 115 + process.sleep(step) 116 + handle_input_window_go(snap, view, remaining - step, step) 117 + } 118 + None -> { 119 + process.sleep(step) 120 + handle_input_window_go(snap, view, remaining - step, step) 121 + } 122 + } 123 + } 124 + } 125 + } 126 + 127 + fn refresh(snap: Snapshot) -> Snapshot { 128 + procfs.sample_with_core_history( 129 + snap.prev_cpu_total, 130 + snap.prev_cpu_idle, 131 + procfs.core_history_from(snap), 132 + snap.prev_proc_ticks, 133 + ) 134 + } 135 + 136 + fn apply_event( 137 + snap: Snapshot, 138 + view: ViewState, 139 + ev: Event, 140 + ) -> #(Snapshot, ViewState, Bool) { 141 + case ev { 142 + Key(ke) -> apply_key(snap, view, ke.code, ke.modifiers.control) 143 + _ -> #(snap, view, False) 144 + } 145 + } 146 + 147 + fn apply_key( 148 + snap: Snapshot, 149 + view: ViewState, 150 + code: KeyCode, 151 + ctrl: Bool, 152 + ) -> #(Snapshot, ViewState, Bool) { 153 + case view.mode { 154 + Help -> #(snap, ui.ViewState(..view, mode: Normal), False) 155 + 156 + ConfirmKill -> 157 + case code { 158 + Char("y") | Char("Y") -> { 159 + case ui.selected_process(view, snap) { 160 + Ok(p) -> { 161 + let _ = 162 + os_cmd("kill -15 " <> int.to_string(p.pid) <> " 2>/dev/null") 163 + Nil 164 + } 165 + Error(_) -> Nil 166 + } 167 + #(refresh(snap), ui.ViewState(..view, mode: Normal), False) 168 + } 169 + Char("n") | Char("N") | Esc -> #( 170 + snap, 171 + ui.ViewState(..view, mode: Normal), 172 + False, 173 + ) 174 + _ -> #(snap, view, False) 175 + } 176 + 177 + Filter -> 178 + case code { 179 + Esc -> #( 180 + snap, 181 + ui.ViewState(..view, mode: Normal, filter: "", selected: 0, scroll: 0), 182 + False, 183 + ) 184 + Enter -> #( 185 + snap, 186 + ui.ViewState(..view, mode: Normal, selected: 0, scroll: 0), 187 + False, 188 + ) 189 + Backspace -> { 190 + let f = string.drop_end(view.filter, 1) 191 + #( 192 + snap, 193 + ui.ViewState(..view, filter: f, selected: 0, scroll: 0), 194 + False, 195 + ) 196 + } 197 + Char(c) -> 198 + case string.byte_size(c) == 1 && !ctrl && is_printable(c) { 199 + True -> #( 200 + snap, 201 + ui.ViewState( 202 + ..view, 203 + filter: view.filter <> c, 204 + selected: 0, 205 + scroll: 0, 206 + ), 207 + False, 208 + ) 209 + False -> #(snap, view, False) 210 + } 211 + _ -> #(snap, view, False) 212 + } 213 + 214 + Normal -> 215 + case code { 216 + Char("q") | Char("Q") | Esc -> #(snap, view, True) 217 + Char("h") | Char("?") -> #( 218 + snap, 219 + ui.ViewState(..view, mode: Help), 220 + False, 221 + ) 222 + Char("/") -> #(snap, ui.ViewState(..view, mode: Filter), False) 223 + Char("k") | Char("K") -> 224 + case ui.selected_process(view, snap) { 225 + Ok(_) -> #(snap, ui.ViewState(..view, mode: ConfirmKill), False) 226 + Error(_) -> #(snap, view, False) 227 + } 228 + Char("j") | DownArrow -> #(snap, move_sel(view, snap, 1), False) 229 + Char("J") -> #(snap, move_sel(view, snap, 10), False) 230 + UpArrow | Char("u") -> #(snap, move_sel(view, snap, -1), False) 231 + PageDown -> #(snap, move_sel(view, snap, page(view)), False) 232 + PageUp -> #(snap, move_sel(view, snap, -page(view)), False) 233 + Home | Char("g") -> #( 234 + snap, 235 + ui.ViewState(..view, selected: 0, scroll: 0), 236 + False, 237 + ) 238 + End | Char("G") -> { 239 + let n = list.length(ui.visible_processes(view, snap)) 240 + let sel = int.max(0, n - 1) 241 + #(snap, ui.ViewState(..view, selected: sel), False) 242 + } 243 + Char("c") -> #(snap, set_sort(view, SortCpu), False) 244 + Char("m") -> #(snap, set_sort(view, SortMem), False) 245 + Char("p") -> #(snap, set_sort(view, SortPid), False) 246 + Char("t") -> #(snap, set_sort(view, SortTime), False) 247 + Char("n") -> #(snap, set_sort(view, SortCommand), False) 248 + Char("r") -> #( 249 + snap, 250 + ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0), 251 + False, 252 + ) 253 + Char(" ") -> #(refresh(snap), view, False) 254 + _ -> #(snap, view, False) 255 + } 256 + } 257 + } 258 + 259 + fn is_printable(c: String) -> Bool { 260 + case string.to_utf_codepoints(c) { 261 + [cp] -> { 262 + let n = string.utf_codepoint_to_int(cp) 263 + n >= 32 && n <= 126 264 + } 265 + _ -> False 266 + } 267 + } 268 + 269 + fn set_sort(view: ViewState, key: SortKey) -> ViewState { 270 + case view.sort == key { 271 + True -> ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0) 272 + False -> 273 + ui.ViewState( 274 + ..view, 275 + sort: key, 276 + reverse: case key { 277 + SortCpu | SortMem | SortTime -> True 278 + SortPid | SortCommand -> False 279 + }, 280 + selected: 0, 281 + scroll: 0, 282 + ) 283 + } 284 + } 285 + 286 + fn move_sel(view: ViewState, snap: Snapshot, delta: Int) -> ViewState { 287 + let n = list.length(ui.visible_processes(view, snap)) 288 + let sel = int.clamp(view.selected + delta, 0, int.max(0, n - 1)) 289 + ui.ViewState(..view, selected: sel) 290 + } 291 + 292 + fn page(view: ViewState) -> Int { 293 + int.max(5, view.rows / 2) 294 + } 295 + 296 + fn window_size() -> #(Int, Int) { 297 + case tty.window_size() { 298 + Ok(#(c, r)) -> #(c, r) 299 + Error(_) -> #(100, 30) 300 + } 301 + } 302 + 303 + fn cleanup() -> Nil { 304 + stdout.execute([ 305 + command.EnableLineWrap, 306 + command.ShowCursor, 307 + command.Clear(terminal.All), 308 + command.LeaveAlternateScreen, 309 + ]) 310 + let _ = tty.exit_raw() 311 + halt(0) 312 + }
+192
src/format.gleam
··· 1 + //// Human-readable sizes, percentages, meter bars, and sparklines. 2 + //// Visual style inspired by btop; table density inspired by htop. 3 + 4 + import gleam/float 5 + import gleam/int 6 + import gleam/list 7 + import gleam/string 8 + 9 + /// Format kibibytes as human size (K/M/G/T). 10 + pub fn human_kb(kb: Int) -> String { 11 + let b = int.to_float(kb) 12 + case kb < 1024 { 13 + True -> int.to_string(kb) <> "K" 14 + False -> 15 + case kb < 1024 * 1024 { 16 + True -> one_decimal(b /. 1024.0) <> "M" 17 + False -> 18 + case kb < 1024 * 1024 * 1024 { 19 + True -> one_decimal(b /. 1_048_576.0) <> "G" 20 + False -> one_decimal(b /. 1_073_741_824.0) <> "T" 21 + } 22 + } 23 + } 24 + } 25 + 26 + pub fn one_decimal(x: Float) -> String { 27 + let x = float.to_precision(x, 1) 28 + float.to_string(x) 29 + } 30 + 31 + pub fn percent(p: Float) -> String { 32 + let p = float.clamp(p, 0.0, 999.9) 33 + case p <. 10.0 { 34 + True -> pad_start(one_decimal(p), 4) <> "%" 35 + False -> 36 + case p <. 100.0 { 37 + True -> pad_start(one_decimal(p), 4) <> "%" 38 + False -> pad_start(int.to_string(float.round(p)), 4) <> "%" 39 + } 40 + } 41 + } 42 + 43 + pub fn percent_short(p: Float) -> String { 44 + one_decimal(float.clamp(p, 0.0, 100.0)) <> "%" 45 + } 46 + 47 + fn pad_start(s: String, width: Int) -> String { 48 + string.pad_start(s, to: width, with: " ") 49 + } 50 + 51 + pub fn pad_end(s: String, width: Int) -> String { 52 + string.pad_end(s, to: width, with: " ") 53 + } 54 + 55 + pub fn pad_left(s: String, width: Int) -> String { 56 + string.pad_start(s, to: width, with: " ") 57 + } 58 + 59 + pub fn truncate(s: String, width: Int) -> String { 60 + case string.length(s) <= width { 61 + True -> s 62 + False -> 63 + case width <= 1 { 64 + True -> string.slice(s, 0, width) 65 + False -> string.slice(s, 0, width - 1) <> "…" 66 + } 67 + } 68 + } 69 + 70 + pub fn fit(s: String, width: Int) -> String { 71 + truncate(s, width) |> pad_end(width) 72 + } 73 + 74 + pub fn fit_right(s: String, width: Int) -> String { 75 + truncate(s, width) |> pad_left(width) 76 + } 77 + 78 + /// Solid meter: filled blocks + empty blocks. width is total cells. 79 + pub fn meter(percent: Float, width: Int) -> String { 80 + let p = float.clamp(percent, 0.0, 100.0) 81 + let filled = float.round(p /. 100.0 *. int.to_float(width)) 82 + let filled = int.clamp(filled, 0, width) 83 + string.repeat("█", filled) <> string.repeat("░", width - filled) 84 + } 85 + 86 + /// Gradient meter characters for finer resolution (btop-ish). 87 + pub fn meter_fine(percent: Float, width: Int) -> String { 88 + let p = float.clamp(percent, 0.0, 100.0) 89 + let cells = p /. 100.0 *. int.to_float(width) 90 + let full = float.truncate(cells) 91 + let frac = cells -. int.to_float(full) 92 + // Left-block partials: ▉ ▊ ▋ ▌ ▍ ▎ ▏ 93 + let partial = case frac { 94 + f if f >=. 0.875 -> "\u{2589}" 95 + f if f >=. 0.75 -> "\u{258A}" 96 + f if f >=. 0.625 -> "\u{258B}" 97 + f if f >=. 0.5 -> "\u{258C}" 98 + f if f >=. 0.375 -> "\u{258D}" 99 + f if f >=. 0.25 -> "\u{258E}" 100 + f if f >=. 0.125 -> "\u{258F}" 101 + f if f >. 0.0 -> "\u{258F}" 102 + _ -> "" 103 + } 104 + let full = int.clamp(full, 0, width) 105 + let body = string.repeat("█", full) <> partial 106 + let len = string.length(body) 107 + case len >= width { 108 + True -> string.slice(body, 0, width) 109 + False -> body <> string.repeat(" ", width - len) 110 + } 111 + } 112 + 113 + /// Sparkline from 0–100 history values. 114 + pub fn sparkline(history: List(Float), width: Int) -> String { 115 + let chars = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] 116 + let n = list.length(chars) 117 + let hist = case list.length(history) > width { 118 + True -> list.drop(history, list.length(history) - width) 119 + False -> history 120 + } 121 + let padded = case list.length(hist) < width { 122 + True -> list.append(list.repeat(0.0, width - list.length(hist)), hist) 123 + False -> hist 124 + } 125 + padded 126 + |> list.map(fn(v) { 127 + let v = float.clamp(v, 0.0, 100.0) 128 + let idx = 129 + float.round(v /. 100.0 *. int.to_float(n - 1)) 130 + |> int.clamp(0, n - 1) 131 + case list.drop(chars, idx) { 132 + [c, ..] -> c 133 + [] -> " " 134 + } 135 + }) 136 + |> string.concat 137 + } 138 + 139 + pub fn format_uptime(secs: Float) -> String { 140 + let total = float.truncate(secs) 141 + let days = total / 86_400 142 + let hours = int.remainder(total / 3600, 24) |> result_or_0 143 + let mins = int.remainder(total / 60, 60) |> result_or_0 144 + case days > 0 { 145 + True -> 146 + int.to_string(days) 147 + <> "d " 148 + <> int.to_string(hours) 149 + <> "h " 150 + <> int.to_string(mins) 151 + <> "m" 152 + False -> 153 + case hours > 0 { 154 + True -> int.to_string(hours) <> "h " <> int.to_string(mins) <> "m" 155 + False -> int.to_string(mins) <> "m" 156 + } 157 + } 158 + } 159 + 160 + fn result_or_0(r: Result(Int, Nil)) -> Int { 161 + case r { 162 + Ok(n) -> n 163 + Error(_) -> 0 164 + } 165 + } 166 + 167 + pub fn format_load(a: Float, b: Float, c: Float) -> String { 168 + one_decimal(a) <> " " <> one_decimal(b) <> " " <> one_decimal(c) 169 + } 170 + 171 + /// Color band for usage: green / yellow / red thresholds. 172 + pub type Band { 173 + Low 174 + Mid 175 + High 176 + Critical 177 + } 178 + 179 + pub fn band(percent: Float) -> Band { 180 + case percent >=. 90.0 { 181 + True -> Critical 182 + False -> 183 + case percent >=. 70.0 { 184 + True -> High 185 + False -> 186 + case percent >=. 40.0 { 187 + True -> Mid 188 + False -> Low 189 + } 190 + } 191 + } 192 + }
+11
src/monitor.gleam
··· 1 + //// monitor — a Gleam system monitor between htop and btop. 2 + //// 3 + //// * btop-style CPU/memory meters, sparklines, and color bands 4 + //// * htop-style process table (PID, USER, PRI, NI, VIRT, RES, S, CPU%, MEM%, TIME+, Command) 5 + //// * sort, filter, kill, keyboard navigation 6 + 7 + import app 8 + 9 + pub fn main() -> Nil { 10 + app.run() 11 + }
+553
src/procfs.gleam
··· 1 + //// Read Linux /proc for CPU, memory, load, and process info. 2 + //// CPU and process percentages need two samples; the first sample 3 + //// returns 0% and seeds history for the next refresh. 4 + 5 + import filepath 6 + import gleam/dict.{type Dict} 7 + import gleam/float 8 + import gleam/int 9 + import gleam/list 10 + import gleam/result 11 + import gleam/string 12 + import simplifile 13 + 14 + pub type CpuCore { 15 + CpuCore(name: String, total: Int, idle: Int, percent: Float) 16 + } 17 + 18 + pub type MemInfo { 19 + MemInfo( 20 + total_kb: Int, 21 + available_kb: Int, 22 + used_kb: Int, 23 + free_kb: Int, 24 + buffers_kb: Int, 25 + cached_kb: Int, 26 + swap_total_kb: Int, 27 + swap_free_kb: Int, 28 + swap_used_kb: Int, 29 + used_percent: Float, 30 + swap_percent: Float, 31 + ) 32 + } 33 + 34 + pub type Process { 35 + Process( 36 + pid: Int, 37 + user: String, 38 + priority: Int, 39 + nice: Int, 40 + virt_kb: Int, 41 + res_kb: Int, 42 + state: String, 43 + cpu_percent: Float, 44 + mem_percent: Float, 45 + time_ticks: Int, 46 + command: String, 47 + /// utime + stime from /proc/[pid]/stat (for delta calc) 48 + cpu_ticks: Int, 49 + ) 50 + } 51 + 52 + pub type Snapshot { 53 + Snapshot( 54 + hostname: String, 55 + uptime_secs: Float, 56 + load1: Float, 57 + load5: Float, 58 + load15: Float, 59 + cores: List(CpuCore), 60 + cpu_total_percent: Float, 61 + mem: MemInfo, 62 + processes: List(Process), 63 + process_count: Int, 64 + thread_count: Int, 65 + /// Aggregate jiffies from the "cpu " line (for next delta) 66 + prev_cpu_total: Int, 67 + prev_cpu_idle: Int, 68 + /// pid -> cpu_ticks for next sample 69 + prev_proc_ticks: Dict(Int, Int), 70 + ) 71 + } 72 + 73 + const page_kb = 4 74 + 75 + const clk_tck = 100 76 + 77 + fn read_hostname() -> String { 78 + case simplifile.read("/proc/sys/kernel/hostname") { 79 + Ok(s) -> string.trim(s) 80 + Error(_) -> "localhost" 81 + } 82 + } 83 + 84 + fn read_uptime() -> Float { 85 + case simplifile.read("/proc/uptime") { 86 + Ok(s) -> 87 + case string.split(string.trim(s), " ") { 88 + [u, ..] -> result.unwrap(float.parse(u), 0.0) 89 + _ -> 0.0 90 + } 91 + Error(_) -> 0.0 92 + } 93 + } 94 + 95 + fn read_loadavg() -> #(Float, Float, Float) { 96 + case simplifile.read("/proc/loadavg") { 97 + Ok(s) -> 98 + case string.split(string.trim(s), " ") { 99 + [a, b, c, ..] -> #( 100 + result.unwrap(float.parse(a), 0.0), 101 + result.unwrap(float.parse(b), 0.0), 102 + result.unwrap(float.parse(c), 0.0), 103 + ) 104 + _ -> #(0.0, 0.0, 0.0) 105 + } 106 + Error(_) -> #(0.0, 0.0, 0.0) 107 + } 108 + } 109 + 110 + /// Sample system state. Pass previous core totals for accurate per-core CPU %. 111 + pub fn sample_with_core_history( 112 + prev_cpu_total: Int, 113 + prev_cpu_idle: Int, 114 + prev_cores: Dict(String, #(Int, Int)), 115 + prev_proc_ticks: Dict(Int, Int), 116 + ) -> Snapshot { 117 + let hostname = read_hostname() 118 + let uptime_secs = read_uptime() 119 + let #(load1, load5, load15) = read_loadavg() 120 + 121 + let #(cores, cpu_total, cpu_idle, cpu_total_percent) = case 122 + simplifile.read("/proc/stat") 123 + { 124 + Ok(content) -> { 125 + let lines = 126 + string.split(content, "\n") 127 + |> list.filter(fn(line) { string.starts_with(line, "cpu") }) 128 + 129 + let core_rows = 130 + list.filter_map(lines, fn(line) { 131 + case string.split(line, " ") { 132 + [name, ..fields] if name != "cpu" -> { 133 + let nums = parse_ints(fields) 134 + let total = list.fold(nums, 0, int.add) 135 + let idle = field_at(nums, 3) + field_at(nums, 4) 136 + let pct = case dict.get(prev_cores, name) { 137 + Ok(#(pt, pi)) -> cpu_percent(pt, pi, total, idle) 138 + Error(_) -> 0.0 139 + } 140 + Ok(CpuCore(name: name, total: total, idle: idle, percent: pct)) 141 + } 142 + _ -> Error(Nil) 143 + } 144 + }) 145 + 146 + let #(agg_total, agg_idle) = case lines { 147 + [first, ..] -> 148 + case string.split(first, " ") { 149 + ["cpu", ..fields] -> { 150 + let nums = parse_ints(fields) 151 + #( 152 + list.fold(nums, 0, int.add), 153 + field_at(nums, 3) + field_at(nums, 4), 154 + ) 155 + } 156 + _ -> #(0, 0) 157 + } 158 + _ -> #(0, 0) 159 + } 160 + 161 + let total_pct = 162 + cpu_percent(prev_cpu_total, prev_cpu_idle, agg_total, agg_idle) 163 + #(core_rows, agg_total, agg_idle, total_pct) 164 + } 165 + Error(_) -> #([], 0, 0, 0.0) 166 + } 167 + 168 + let mem = read_mem() 169 + let #(processes, proc_ticks, threads) = 170 + read_processes(prev_cpu_total, cpu_total, prev_proc_ticks, mem.total_kb) 171 + 172 + Snapshot( 173 + hostname: hostname, 174 + uptime_secs: uptime_secs, 175 + load1: load1, 176 + load5: load5, 177 + load15: load15, 178 + cores: cores, 179 + cpu_total_percent: cpu_total_percent, 180 + mem: mem, 181 + processes: processes, 182 + process_count: list.length(processes), 183 + thread_count: threads, 184 + prev_cpu_total: cpu_total, 185 + prev_cpu_idle: cpu_idle, 186 + prev_proc_ticks: proc_ticks, 187 + ) 188 + } 189 + 190 + pub fn core_history_from(snap: Snapshot) -> Dict(String, #(Int, Int)) { 191 + list.fold(snap.cores, dict.new(), fn(acc, c) { 192 + dict.insert(acc, c.name, #(c.total, c.idle)) 193 + }) 194 + } 195 + 196 + fn cpu_percent( 197 + prev_total: Int, 198 + prev_idle: Int, 199 + total: Int, 200 + idle: Int, 201 + ) -> Float { 202 + let d_total = total - prev_total 203 + let d_idle = idle - prev_idle 204 + case d_total <= 0 || prev_total == 0 { 205 + True -> 0.0 206 + False -> { 207 + let busy = d_total - d_idle 208 + float.clamp( 209 + int.to_float(busy) /. int.to_float(d_total) *. 100.0, 210 + 0.0, 211 + 100.0, 212 + ) 213 + } 214 + } 215 + } 216 + 217 + fn read_mem() -> MemInfo { 218 + case simplifile.read("/proc/meminfo") { 219 + Ok(content) -> { 220 + let d = parse_meminfo(content) 221 + let total = mem_get(d, "MemTotal") 222 + let available = mem_get(d, "MemAvailable") 223 + let free = mem_get(d, "MemFree") 224 + let buffers = mem_get(d, "Buffers") 225 + let cached = mem_get(d, "Cached") + mem_get(d, "SReclaimable") 226 + let used = int.max(0, total - available) 227 + let swap_total = mem_get(d, "SwapTotal") 228 + let swap_free = mem_get(d, "SwapFree") 229 + let swap_used = int.max(0, swap_total - swap_free) 230 + let used_pct = case total { 231 + 0 -> 0.0 232 + t -> int.to_float(used) /. int.to_float(t) *. 100.0 233 + } 234 + let swap_pct = case swap_total { 235 + 0 -> 0.0 236 + t -> int.to_float(swap_used) /. int.to_float(t) *. 100.0 237 + } 238 + MemInfo( 239 + total_kb: total, 240 + available_kb: available, 241 + used_kb: used, 242 + free_kb: free, 243 + buffers_kb: buffers, 244 + cached_kb: cached, 245 + swap_total_kb: swap_total, 246 + swap_free_kb: swap_free, 247 + swap_used_kb: swap_used, 248 + used_percent: used_pct, 249 + swap_percent: swap_pct, 250 + ) 251 + } 252 + Error(_) -> MemInfo(0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0) 253 + } 254 + } 255 + 256 + fn parse_meminfo(content: String) -> Dict(String, Int) { 257 + string.split(content, "\n") 258 + |> list.fold(dict.new(), fn(acc, line) { 259 + case string.split(line, ":") { 260 + [key, rest] -> { 261 + let num = 262 + string.trim(rest) 263 + |> string.replace(" kB", "") 264 + |> string.trim 265 + |> int.parse 266 + |> result.unwrap(0) 267 + dict.insert(acc, string.trim(key), num) 268 + } 269 + _ -> acc 270 + } 271 + }) 272 + } 273 + 274 + fn mem_get(d: Dict(String, Int), key: String) -> Int { 275 + result.unwrap(dict.get(d, key), 0) 276 + } 277 + 278 + fn read_processes( 279 + prev_cpu_total: Int, 280 + cpu_total: Int, 281 + prev_proc_ticks: Dict(Int, Int), 282 + mem_total_kb: Int, 283 + ) -> #(List(Process), Dict(Int, Int), Int) { 284 + let users = load_users() 285 + let pids = list_pids() 286 + let delta_cpu = cpu_total - prev_cpu_total 287 + 288 + let #(procs, ticks, threads) = 289 + list.fold(pids, #([], dict.new(), 0), fn(acc, pid) { 290 + let #(out, tick_map, thr) = acc 291 + case 292 + parse_process( 293 + pid, 294 + users, 295 + prev_proc_ticks, 296 + delta_cpu, 297 + prev_cpu_total, 298 + mem_total_kb, 299 + ) 300 + { 301 + Ok(#(proc, n_threads)) -> #( 302 + [proc, ..out], 303 + dict.insert(tick_map, proc.pid, proc.cpu_ticks), 304 + thr + n_threads, 305 + ) 306 + Error(_) -> acc 307 + } 308 + }) 309 + 310 + #(procs, ticks, threads) 311 + } 312 + 313 + fn list_pids() -> List(Int) { 314 + case simplifile.read_directory("/proc") { 315 + Ok(entries) -> 316 + list.filter_map(entries, fn(name) { 317 + case int.parse(name) { 318 + Ok(pid) -> Ok(pid) 319 + Error(_) -> Error(Nil) 320 + } 321 + }) 322 + Error(_) -> [] 323 + } 324 + } 325 + 326 + fn parse_process( 327 + pid: Int, 328 + users: Dict(Int, String), 329 + prev_ticks: Dict(Int, Int), 330 + delta_cpu: Int, 331 + prev_cpu_total: Int, 332 + mem_total_kb: Int, 333 + ) -> Result(#(Process, Int), Nil) { 334 + let base = filepath.join("/proc", int.to_string(pid)) 335 + use stat_raw <- result.try(result.replace_error( 336 + simplifile.read(base <> "/stat"), 337 + Nil, 338 + )) 339 + use #(state, priority, nice, utime, stime, vsize, rss, num_threads, comm) <- result.try( 340 + parse_stat(stat_raw), 341 + ) 342 + 343 + let uid = read_uid(base <> "/status") 344 + let user = case dict.get(users, uid) { 345 + Ok(u) -> u 346 + Error(_) -> int.to_string(uid) 347 + } 348 + 349 + let command = case simplifile.read(base <> "/cmdline") { 350 + Ok(cmd) if cmd != "" -> 351 + string.replace(cmd, "\u{0000}", " ") 352 + |> string.trim 353 + _ -> comm 354 + } 355 + 356 + let cpu_ticks = utime + stime 357 + let virt_kb = vsize / 1024 358 + let res_kb = rss * page_kb 359 + 360 + let cpu_pct = case prev_cpu_total == 0 || delta_cpu <= 0 { 361 + True -> 0.0 362 + False -> 363 + case dict.get(prev_ticks, pid) { 364 + Ok(prev) -> { 365 + let d = cpu_ticks - prev 366 + float.clamp( 367 + int.to_float(d) /. int.to_float(delta_cpu) *. 100.0, 368 + 0.0, 369 + 100.0 *. 64.0, 370 + ) 371 + } 372 + Error(_) -> 0.0 373 + } 374 + } 375 + 376 + let mem_pct = case mem_total_kb { 377 + 0 -> 0.0 378 + t -> int.to_float(res_kb) /. int.to_float(t) *. 100.0 379 + } 380 + 381 + Ok(#( 382 + Process( 383 + pid: pid, 384 + user: user, 385 + priority: priority, 386 + nice: nice, 387 + virt_kb: virt_kb, 388 + res_kb: res_kb, 389 + state: state, 390 + cpu_percent: cpu_pct, 391 + mem_percent: mem_pct, 392 + time_ticks: cpu_ticks, 393 + command: command, 394 + cpu_ticks: cpu_ticks, 395 + ), 396 + num_threads, 397 + )) 398 + } 399 + 400 + /// Parse /proc/[pid]/stat — comm is inside parentheses and may contain spaces. 401 + fn parse_stat( 402 + raw: String, 403 + ) -> Result(#(String, Int, Int, Int, Int, Int, Int, Int, String), Nil) { 404 + let raw = string.trim(raw) 405 + use #(_, rest0) <- result.try(split_once_char(raw, "(")) 406 + use #(comm, after) <- result.try(rsplit_once_char(rest0, ")")) 407 + let fields = 408 + string.trim(after) 409 + |> string.split(" ") 410 + |> list.filter(fn(s) { s != "" }) 411 + 412 + // After comm: state(0) ppid(1) ... utime(11) stime(12) ... priority(15) 413 + // nice(16) num_threads(17) ... vsize(20) rss(21) 414 + use state <- result.try(list_at(fields, 0)) 415 + let priority = 416 + result.unwrap(int.parse(result.unwrap(list_at(fields, 15), "0")), 0) 417 + let nice = 418 + result.unwrap(int.parse(result.unwrap(list_at(fields, 16), "0")), 0) 419 + let utime = 420 + result.unwrap(int.parse(result.unwrap(list_at(fields, 11), "0")), 0) 421 + let stime = 422 + result.unwrap(int.parse(result.unwrap(list_at(fields, 12), "0")), 0) 423 + let num_threads = 424 + result.unwrap(int.parse(result.unwrap(list_at(fields, 17), "1")), 1) 425 + let vsize = 426 + result.unwrap(int.parse(result.unwrap(list_at(fields, 20), "0")), 0) 427 + let rss = result.unwrap(int.parse(result.unwrap(list_at(fields, 21), "0")), 0) 428 + 429 + Ok(#(state, priority, nice, utime, stime, vsize, rss, num_threads, comm)) 430 + } 431 + 432 + fn read_uid(status_path: String) -> Int { 433 + case simplifile.read(status_path) { 434 + Ok(content) -> { 435 + content 436 + |> string.split("\n") 437 + |> list.find_map(fn(line) { 438 + case string.starts_with(line, "Uid:") { 439 + True -> { 440 + let parts = string.split(string.trim(line), "\t") 441 + case parts { 442 + [_, uid, ..] -> 443 + case int.parse(string.trim(uid)) { 444 + Ok(n) -> Ok(n) 445 + Error(_) -> { 446 + // sometimes spaces not tabs 447 + let parts2 = 448 + string.split(line, " ") 449 + |> list.filter(fn(s) { s != "" }) 450 + case parts2 { 451 + [_, uid2, ..] -> 452 + result.replace_error(int.parse(uid2), Nil) 453 + _ -> Error(Nil) 454 + } 455 + } 456 + } 457 + _ -> Error(Nil) 458 + } 459 + } 460 + False -> Error(Nil) 461 + } 462 + }) 463 + |> result.unwrap(0) 464 + } 465 + Error(_) -> 0 466 + } 467 + } 468 + 469 + fn load_users() -> Dict(Int, String) { 470 + case simplifile.read("/etc/passwd") { 471 + Ok(content) -> 472 + string.split(content, "\n") 473 + |> list.fold(dict.new(), fn(acc, line) { 474 + case string.split(line, ":") { 475 + [name, _, uid_s, ..] -> 476 + case int.parse(uid_s) { 477 + Ok(uid) -> dict.insert(acc, uid, name) 478 + Error(_) -> acc 479 + } 480 + _ -> acc 481 + } 482 + }) 483 + Error(_) -> dict.new() 484 + } 485 + } 486 + 487 + pub fn ticks_to_time(ticks: Int) -> String { 488 + let total_cs = ticks 489 + // centiseconds if clk_tck=100 490 + let total_secs = total_cs / clk_tck 491 + let cs = int.remainder(total_cs, clk_tck) |> result.unwrap(0) 492 + let hours = total_secs / 3600 493 + let mins = int.remainder(total_secs / 60, 60) |> result.unwrap(0) 494 + let secs = int.remainder(total_secs, 60) |> result.unwrap(0) 495 + case hours > 0 { 496 + True -> int.to_string(hours) <> ":" <> pad2(mins) <> ":" <> pad2(secs) 497 + False -> int.to_string(mins) <> ":" <> pad2(secs) <> "." <> pad2(cs) 498 + } 499 + } 500 + 501 + fn pad2(n: Int) -> String { 502 + case n < 10 { 503 + True -> "0" <> int.to_string(n) 504 + False -> int.to_string(n) 505 + } 506 + } 507 + 508 + fn parse_ints(fields: List(String)) -> List(Int) { 509 + list.filter_map(fields, fn(s) { 510 + case s == "" { 511 + True -> Error(Nil) 512 + False -> result.replace_error(int.parse(s), Nil) 513 + } 514 + }) 515 + } 516 + 517 + fn field_at(nums: List(Int), index: Int) -> Int { 518 + result.unwrap(list_at(nums, index), 0) 519 + } 520 + 521 + fn list_at(items: List(a), index: Int) -> Result(a, Nil) { 522 + case index < 0 { 523 + True -> Error(Nil) 524 + False -> { 525 + case list.drop(items, index) { 526 + [x, ..] -> Ok(x) 527 + [] -> Error(Nil) 528 + } 529 + } 530 + } 531 + } 532 + 533 + fn split_once_char(s: String, char: String) -> Result(#(String, String), Nil) { 534 + case string.split_once(s, char) { 535 + Ok(pair) -> Ok(pair) 536 + Error(_) -> Error(Nil) 537 + } 538 + } 539 + 540 + fn rsplit_once_char(s: String, char: String) -> Result(#(String, String), Nil) { 541 + // Find last occurrence 542 + case string.split(s, char) { 543 + [] -> Error(Nil) 544 + [_] -> Error(Nil) 545 + parts -> { 546 + let rev = list.reverse(parts) 547 + case rev { 548 + [last, ..front] -> Ok(#(string.join(list.reverse(front), char), last)) 549 + _ -> Error(Nil) 550 + } 551 + } 552 + } 553 + }
+662
src/ui.gleam
··· 1 + //// Terminal rendering: btop-style meters + htop-style process table. 2 + 3 + import etch/command.{type Command} 4 + import etch/style 5 + import etch/terminal 6 + import format 7 + import gleam/int 8 + import gleam/list 9 + import gleam/order.{type Order, Eq, Gt, Lt} 10 + import gleam/string 11 + import procfs.{type Process, type Snapshot} 12 + 13 + pub type SortKey { 14 + SortCpu 15 + SortMem 16 + SortPid 17 + SortTime 18 + SortCommand 19 + } 20 + 21 + pub type Mode { 22 + Normal 23 + Filter 24 + Help 25 + ConfirmKill 26 + } 27 + 28 + pub type ViewState { 29 + ViewState( 30 + selected: Int, 31 + scroll: Int, 32 + sort: SortKey, 33 + reverse: Bool, 34 + filter: String, 35 + mode: Mode, 36 + cpu_history: List(Float), 37 + mem_history: List(Float), 38 + cols: Int, 39 + rows: Int, 40 + ) 41 + } 42 + 43 + pub fn initial_view(cols: Int, rows: Int) -> ViewState { 44 + ViewState( 45 + selected: 0, 46 + scroll: 0, 47 + sort: SortCpu, 48 + reverse: True, 49 + filter: "", 50 + mode: Normal, 51 + cpu_history: [], 52 + mem_history: [], 53 + cols: cols, 54 + rows: rows, 55 + ) 56 + } 57 + 58 + pub fn push_history(view: ViewState, snap: Snapshot) -> ViewState { 59 + let max = 60 60 + let cpu = list.append(view.cpu_history, [snap.cpu_total_percent]) 61 + let mem = list.append(view.mem_history, [snap.mem.used_percent]) 62 + let cpu = case list.length(cpu) > max { 63 + True -> list.drop(cpu, list.length(cpu) - max) 64 + False -> cpu 65 + } 66 + let mem = case list.length(mem) > max { 67 + True -> list.drop(mem, list.length(mem) - max) 68 + False -> mem 69 + } 70 + ViewState(..view, cpu_history: cpu, mem_history: mem) 71 + } 72 + 73 + pub fn visible_processes(view: ViewState, snap: Snapshot) -> List(Process) { 74 + let filtered = case view.filter { 75 + "" -> snap.processes 76 + f -> { 77 + let f = string.lowercase(f) 78 + list.filter(snap.processes, fn(p) { 79 + string.contains(string.lowercase(p.command), f) 80 + || string.contains(string.lowercase(p.user), f) 81 + || string.contains(int.to_string(p.pid), f) 82 + }) 83 + } 84 + } 85 + let sorted = sort_processes(filtered, view.sort) 86 + case view.reverse { 87 + True -> list.reverse(sorted) 88 + False -> sorted 89 + } 90 + } 91 + 92 + fn sort_processes(procs: List(Process), key: SortKey) -> List(Process) { 93 + list.sort(procs, fn(a, b) { 94 + case key { 95 + SortCpu -> float_ord(a.cpu_percent, b.cpu_percent) 96 + SortMem -> float_ord(a.mem_percent, b.mem_percent) 97 + SortPid -> int.compare(a.pid, b.pid) 98 + SortTime -> int.compare(a.time_ticks, b.time_ticks) 99 + SortCommand -> string.compare(a.command, b.command) 100 + } 101 + }) 102 + } 103 + 104 + fn float_ord(a: Float, b: Float) -> Order { 105 + case a <. b { 106 + True -> Lt 107 + False -> 108 + case a >. b { 109 + True -> Gt 110 + False -> Eq 111 + } 112 + } 113 + } 114 + 115 + /// Build a full frame as a list of etch commands. 116 + pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { 117 + let cols = int.max(view.cols, 60) 118 + let rows = int.max(view.rows, 16) 119 + let procs = visible_processes(view, snap) 120 + let proc_count = list.length(procs) 121 + 122 + // Layout: header(1) + cpu block + mem(2) + blank + table header + rows + footer 123 + let core_count = list.length(snap.cores) 124 + // CPU section: 1 line total + ceil(cores/cols_per_row) lines, max ~4 lines 125 + let cores_per_row = case cols >= 120 { 126 + True -> 4 127 + False -> 128 + case cols >= 90 { 129 + True -> 3 130 + False -> 2 131 + } 132 + } 133 + let core_rows = case core_count { 134 + 0 -> 0 135 + n -> { 136 + let r = { n + cores_per_row - 1 } / cores_per_row 137 + int.min(r, 4) 138 + } 139 + } 140 + let header_lines = 1 + 1 + core_rows + 2 + 1 141 + // header, cpu total, core rows, mem+swap, blank before table 142 + let footer_lines = 1 143 + let table_header = 1 144 + let available = int.max(rows - header_lines - footer_lines - table_header, 3) 145 + let scroll = clamp_scroll(view.scroll, view.selected, proc_count, available) 146 + let selected = int.clamp(view.selected, 0, int.max(proc_count - 1, 0)) 147 + 148 + let view = 149 + ViewState( 150 + ..view, 151 + scroll: scroll, 152 + selected: selected, 153 + cols: cols, 154 + rows: rows, 155 + ) 156 + 157 + let lines = 158 + list.flatten([ 159 + [draw_header(snap, view, cols)], 160 + [draw_cpu_total(snap, view, cols)], 161 + draw_cores(snap, cores_per_row, core_rows, cols), 162 + [draw_mem(snap, cols), draw_swap(snap, cols)], 163 + [draw_table_header(view, cols)], 164 + draw_process_rows(procs, view, available, cols), 165 + pad_to(available - visible_row_count(procs, view, available), cols), 166 + ]) 167 + 168 + let body = 169 + list.index_map(lines, fn(line, i) { 170 + [command.MoveTo(0, i), command.Print(line)] 171 + }) 172 + |> list.flatten 173 + 174 + let footer_y = rows - 1 175 + let footer = draw_footer(snap, view, proc_count, cols) 176 + 177 + let overlay = case view.mode { 178 + Help -> help_overlay(cols, rows) 179 + ConfirmKill -> kill_overlay(procs, view, cols, rows) 180 + Filter -> [] 181 + Normal -> [] 182 + } 183 + 184 + list.flatten([ 185 + [ 186 + command.HideCursor, 187 + command.MoveTo(0, 0), 188 + command.Clear(terminal.All), 189 + ], 190 + body, 191 + [ 192 + command.MoveTo(0, footer_y), 193 + command.Print(footer), 194 + ], 195 + overlay, 196 + ]) 197 + } 198 + 199 + fn clamp_scroll(scroll: Int, selected: Int, count: Int, height: Int) -> Int { 200 + let scroll = case selected < scroll { 201 + True -> selected 202 + False -> scroll 203 + } 204 + let scroll = case selected >= scroll + height { 205 + True -> selected - height + 1 206 + False -> scroll 207 + } 208 + int.clamp(scroll, 0, int.max(0, count - height)) 209 + } 210 + 211 + fn visible_row_count( 212 + procs: List(Process), 213 + view: ViewState, 214 + height: Int, 215 + ) -> Int { 216 + let n = list.length(procs) 217 + int.min(height, int.max(0, n - view.scroll)) 218 + } 219 + 220 + fn pad_to(n: Int, cols: Int) -> List(String) { 221 + case n <= 0 { 222 + True -> [] 223 + False -> list.repeat(string.repeat(" ", cols), n) 224 + } 225 + } 226 + 227 + fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String { 228 + let title = style_bold(style_cyan(" monitor ")) 229 + let host = style_dim(snap.hostname) 230 + let up = "up " <> format.format_uptime(snap.uptime_secs) 231 + let load = "load " <> format.format_load(snap.load1, snap.load5, snap.load15) 232 + let tasks = 233 + int.to_string(snap.process_count) 234 + <> " procs · " 235 + <> int.to_string(snap.thread_count) 236 + <> " thr" 237 + let sort = 238 + "sort:" 239 + <> sort_label(view.sort) 240 + <> case view.reverse { 241 + True -> "↓" 242 + False -> "↑" 243 + } 244 + let mid = host <> " " <> up <> " " <> load <> " " <> tasks 245 + let right = sort 246 + let left = title 247 + // approx without ansi length — simple pad using raw segments 248 + let raw_left = " monitor " 249 + let raw_mid = snap.hostname <> " " <> up <> " " <> load <> " " <> tasks 250 + let raw_right = "sort:" <> sort_label(view.sort) <> "↓" 251 + let gap = 252 + int.max( 253 + 1, 254 + cols 255 + - string.length(raw_left) 256 + - string.length(raw_mid) 257 + - string.length(raw_right) 258 + - 2, 259 + ) 260 + left 261 + <> style_dim(string.repeat("─", gap / 2)) 262 + <> " " 263 + <> style_dim(mid) 264 + <> " " 265 + <> style_dim(string.repeat("─", gap - gap / 2)) 266 + <> " " 267 + <> style_yellow(right) 268 + <> reset() 269 + |> fit_line(cols) 270 + } 271 + 272 + fn draw_cpu_total(snap: Snapshot, view: ViewState, cols: Int) -> String { 273 + let label = style_bold(" CPU ") 274 + let bar_w = int.clamp(cols / 3, 20, 40) 275 + let bar = colored_meter(snap.cpu_total_percent, bar_w) 276 + let pct = 277 + style_for_band( 278 + snap.cpu_total_percent, 279 + format.percent(snap.cpu_total_percent), 280 + ) 281 + let spark_w = int.clamp(cols - bar_w - 18, 8, 40) 282 + let spark = style_cyan(format.sparkline(view.cpu_history, spark_w)) 283 + label 284 + <> bar 285 + <> " " 286 + <> pct 287 + <> " " 288 + <> spark 289 + <> reset() 290 + |> fit_line(cols) 291 + } 292 + 293 + fn draw_cores( 294 + snap: Snapshot, 295 + per_row: Int, 296 + max_rows: Int, 297 + cols: Int, 298 + ) -> List(String) { 299 + let cores = list.take(snap.cores, max_rows * per_row) 300 + let cell_w = int.max(12, { cols - 1 } / per_row) 301 + list.sized_chunk(cores, per_row) 302 + |> list.map(fn(row) { 303 + row 304 + |> list.map(fn(c) { 305 + let name = string.replace(c.name, "cpu", "") 306 + let label = style_dim(format.pad_left(name, 2) <> " ") 307 + let bar_w = int.max(4, cell_w - 9) 308 + let bar = colored_meter(c.percent, bar_w) 309 + let pct = 310 + style_for_band( 311 + c.percent, 312 + format.fit_right(format.percent_short(c.percent), 5), 313 + ) 314 + format.fit(label <> bar <> pct, cell_w) 315 + }) 316 + |> string.concat 317 + |> fit_line(cols) 318 + }) 319 + } 320 + 321 + fn draw_mem(snap: Snapshot, cols: Int) -> String { 322 + let m = snap.mem 323 + let label = style_bold(" MEM ") 324 + let bar_w = int.clamp(cols / 3, 20, 40) 325 + let bar = colored_meter(m.used_percent, bar_w) 326 + let pct = style_for_band(m.used_percent, format.percent(m.used_percent)) 327 + let detail = 328 + style_dim( 329 + format.human_kb(m.used_kb) 330 + <> "/" 331 + <> format.human_kb(m.total_kb) 332 + <> " free " 333 + <> format.human_kb(m.free_kb) 334 + <> " cache " 335 + <> format.human_kb(m.cached_kb), 336 + ) 337 + label 338 + <> bar 339 + <> " " 340 + <> pct 341 + <> " " 342 + <> detail 343 + <> reset() 344 + |> fit_line(cols) 345 + } 346 + 347 + fn draw_swap(snap: Snapshot, cols: Int) -> String { 348 + let m = snap.mem 349 + let label = style_bold(" SWP ") 350 + let bar_w = int.clamp(cols / 3, 20, 40) 351 + let bar = colored_meter(m.swap_percent, bar_w) 352 + let pct = style_for_band(m.swap_percent, format.percent(m.swap_percent)) 353 + let detail = 354 + style_dim( 355 + format.human_kb(m.swap_used_kb) <> "/" <> format.human_kb(m.swap_total_kb), 356 + ) 357 + label 358 + <> bar 359 + <> " " 360 + <> pct 361 + <> " " 362 + <> detail 363 + <> reset() 364 + |> fit_line(cols) 365 + } 366 + 367 + fn draw_table_header(view: ViewState, cols: Int) -> String { 368 + let mark = fn(key: SortKey, label: String) { 369 + case view.sort == key { 370 + True -> style_bold(style_yellow(label)) 371 + False -> style_bold(label) 372 + } 373 + } 374 + let line = 375 + mark(SortPid, format.fit_right("PID", 7)) 376 + <> " " 377 + <> format.fit("USER", 9) 378 + <> " " 379 + <> format.fit_right("PRI", 3) 380 + <> " " 381 + <> format.fit_right("NI", 3) 382 + <> " " 383 + <> format.fit_right("VIRT", 7) 384 + <> " " 385 + <> format.fit_right("RES", 7) 386 + <> " " 387 + <> "S" 388 + <> " " 389 + <> mark(SortCpu, format.fit_right("CPU%", 6)) 390 + <> " " 391 + <> mark(SortMem, format.fit_right("MEM%", 6)) 392 + <> " " 393 + <> mark(SortTime, format.fit_right("TIME+", 9)) 394 + <> " " 395 + <> mark(SortCommand, "Command") 396 + style_on_bg(line, style.Rgb(30, 30, 46)) 397 + <> reset() 398 + |> fit_line(cols) 399 + } 400 + 401 + fn draw_process_rows( 402 + procs: List(Process), 403 + view: ViewState, 404 + height: Int, 405 + cols: Int, 406 + ) -> List(String) { 407 + procs 408 + |> list.drop(view.scroll) 409 + |> list.take(height) 410 + |> list.index_map(fn(p, i) { 411 + let idx = view.scroll + i 412 + let selected = idx == view.selected 413 + draw_process(p, selected, cols) 414 + }) 415 + } 416 + 417 + fn draw_process(p: Process, selected: Bool, cols: Int) -> String { 418 + let cmd_w = int.max(10, cols - 70) 419 + let base = 420 + format.fit_right(int.to_string(p.pid), 7) 421 + <> " " 422 + <> format.fit(p.user, 9) 423 + <> " " 424 + <> format.fit_right(int.to_string(p.priority), 3) 425 + <> " " 426 + <> format.fit_right(int.to_string(p.nice), 3) 427 + <> " " 428 + <> format.fit_right(format.human_kb(p.virt_kb), 7) 429 + <> " " 430 + <> format.fit_right(format.human_kb(p.res_kb), 7) 431 + <> " " 432 + <> state_color(p.state) 433 + <> " " 434 + <> style_for_band( 435 + p.cpu_percent, 436 + format.fit_right(format.percent(p.cpu_percent), 6), 437 + ) 438 + <> " " 439 + <> style_for_band( 440 + p.mem_percent, 441 + format.fit_right(format.percent(p.mem_percent), 6), 442 + ) 443 + <> " " 444 + <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) 445 + <> " " 446 + <> format.fit(p.command, cmd_w) 447 + 448 + case selected { 449 + True -> 450 + style_on_bg(strip_for_select(p, cmd_w), style.Rgb(69, 71, 90)) 451 + <> reset() 452 + |> fit_line(cols) 453 + False -> base <> reset() |> fit_line(cols) 454 + } 455 + } 456 + 457 + fn strip_for_select(p: Process, cmd_w: Int) -> String { 458 + format.fit_right(int.to_string(p.pid), 7) 459 + <> " " 460 + <> format.fit(p.user, 9) 461 + <> " " 462 + <> format.fit_right(int.to_string(p.priority), 3) 463 + <> " " 464 + <> format.fit_right(int.to_string(p.nice), 3) 465 + <> " " 466 + <> format.fit_right(format.human_kb(p.virt_kb), 7) 467 + <> " " 468 + <> format.fit_right(format.human_kb(p.res_kb), 7) 469 + <> " " 470 + <> p.state 471 + <> " " 472 + <> format.fit_right(format.percent(p.cpu_percent), 6) 473 + <> " " 474 + <> format.fit_right(format.percent(p.mem_percent), 6) 475 + <> " " 476 + <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) 477 + <> " " 478 + <> format.fit(p.command, cmd_w) 479 + } 480 + 481 + fn draw_footer( 482 + snap: Snapshot, 483 + view: ViewState, 484 + visible: Int, 485 + cols: Int, 486 + ) -> String { 487 + let text = case view.mode { 488 + Filter -> " Filter: " <> view.filter <> "█ (Enter apply · Esc cancel)" 489 + ConfirmKill -> " Confirm kill? [y/N]" 490 + Help -> " Help · press any key to close" 491 + Normal -> 492 + " [q]uit [↑↓/uj] move [PgUp/Dn] [/]filter [c/m/p/t/n]sort [r]ev [k]ill [h]elp · " 493 + <> int.to_string(visible) 494 + <> "/" 495 + <> int.to_string(snap.process_count) 496 + <> " shown" 497 + } 498 + style_on_bg(format.fit(text, cols), style.Rgb(30, 30, 46)) 499 + <> reset() 500 + |> fit_line(cols) 501 + } 502 + 503 + fn help_overlay(cols: Int, rows: Int) -> List(Command) { 504 + let w = int.min(56, cols - 4) 505 + let h = 14 506 + let x = int.max(0, { cols - w } / 2) 507 + let y = int.max(1, { rows - h } / 2) 508 + let lines = [ 509 + "┌" <> string.repeat("─", w - 2) <> "┐", 510 + "│" <> format.fit(" monitor — htop × btop in Gleam", w - 2) <> "│", 511 + "│" <> format.fit("", w - 2) <> "│", 512 + "│" <> format.fit(" Navigation", w - 2) <> "│", 513 + "│" <> format.fit(" ↑/u ↓/j move selection", w - 2) <> "│", 514 + "│" <> format.fit(" PgUp/PgDn page · g/G top/end", w - 2) <> "│", 515 + "│" <> format.fit(" Sorting & filter", w - 2) <> "│", 516 + "│" <> format.fit(" c/m/p/t/n CPU MEM PID TIME name", w - 2) <> "│", 517 + "│" <> format.fit(" r reverse · / filter", w - 2) <> "│", 518 + "│" <> format.fit(" Actions", w - 2) <> "│", 519 + "│" <> format.fit(" k kill selected (SIGTERM)", w - 2) <> "│", 520 + "│" <> format.fit(" q / Esc quit", w - 2) <> "│", 521 + "│" <> format.fit(" Meters from btop · table from htop", w - 2) <> "│", 522 + "└" <> string.repeat("─", w - 2) <> "┘", 523 + ] 524 + list.index_map(lines, fn(line, i) { 525 + [ 526 + command.MoveTo(x, y + i), 527 + command.SetStyle( 528 + style.Style( 529 + fg: style.BrightWhite, 530 + bg: style.Rgb(24, 24, 37), 531 + attributes: [style.Bold], 532 + ), 533 + ), 534 + command.Print(format.fit(line, w)), 535 + command.ResetStyle, 536 + ] 537 + }) 538 + |> list.flatten 539 + } 540 + 541 + fn kill_overlay( 542 + procs: List(Process), 543 + view: ViewState, 544 + cols: Int, 545 + rows: Int, 546 + ) -> List(Command) { 547 + let msg = case list_at(procs, view.selected) { 548 + Ok(p) -> 549 + " Kill PID " 550 + <> int.to_string(p.pid) 551 + <> " (" 552 + <> format.truncate(p.command, 30) 553 + <> ")? [y/N] " 554 + Error(_) -> " No process selected " 555 + } 556 + let w = int.min(string.length(msg) + 4, cols - 2) 557 + let x = int.max(0, { cols - w } / 2) 558 + let y = rows / 2 559 + [ 560 + command.MoveTo(x, y), 561 + command.SetStyle( 562 + style.Style(fg: style.Black, bg: style.BrightYellow, attributes: [ 563 + style.Bold, 564 + ]), 565 + ), 566 + command.Print(format.fit(msg, w)), 567 + command.ResetStyle, 568 + ] 569 + } 570 + 571 + fn list_at(items: List(a), index: Int) -> Result(a, Nil) { 572 + case list.drop(items, index) { 573 + [x, ..] -> Ok(x) 574 + [] -> Error(Nil) 575 + } 576 + } 577 + 578 + fn colored_meter(percent: Float, width: Int) -> String { 579 + let bar = format.meter_fine(percent, width) 580 + style_for_band(percent, bar) 581 + } 582 + 583 + fn style_for_band(percent: Float, s: String) -> String { 584 + case format.band(percent) { 585 + format.Low -> style_green(s) 586 + format.Mid -> style_yellow(s) 587 + format.High -> style_magenta(s) 588 + format.Critical -> style_red(s) 589 + } 590 + } 591 + 592 + fn state_color(s: String) -> String { 593 + case s { 594 + "R" -> style_green("R") 595 + "S" -> style_dim("S") 596 + "D" -> style_red("D") 597 + "Z" -> style_red("Z") 598 + "T" | "t" -> style_yellow("T") 599 + _ -> s 600 + } 601 + } 602 + 603 + fn sort_label(k: SortKey) -> String { 604 + case k { 605 + SortCpu -> "CPU" 606 + SortMem -> "MEM" 607 + SortPid -> "PID" 608 + SortTime -> "TIME" 609 + SortCommand -> "CMD" 610 + } 611 + } 612 + 613 + fn fit_line(s: String, _cols: Int) -> String { 614 + // ANSI length ≠ display length; leave padding to the terminal clear. 615 + s <> reset() 616 + } 617 + 618 + // ─── ANSI helpers via etch/style ─────────────────────────────────────────── 619 + 620 + fn reset() -> String { 621 + style.reset_style("") 622 + } 623 + 624 + fn style_bold(s: String) -> String { 625 + style.attributes(s, [style.Bold]) 626 + } 627 + 628 + fn style_dim(s: String) -> String { 629 + style.attributes(s, [style.Dim]) 630 + } 631 + 632 + fn style_cyan(s: String) -> String { 633 + style.with(s, style.Cyan) 634 + } 635 + 636 + fn style_green(s: String) -> String { 637 + style.with(s, style.Green) 638 + } 639 + 640 + fn style_yellow(s: String) -> String { 641 + style.with(s, style.Yellow) 642 + } 643 + 644 + fn style_red(s: String) -> String { 645 + style.with(s, style.Red) 646 + } 647 + 648 + fn style_magenta(s: String) -> String { 649 + style.with(s, style.Magenta) 650 + } 651 + 652 + fn style_on_bg(s: String, bg: style.Color) -> String { 653 + style.on(s, bg) 654 + } 655 + 656 + pub fn selected_process( 657 + view: ViewState, 658 + snap: Snapshot, 659 + ) -> Result(Process, Nil) { 660 + let procs = visible_processes(view, snap) 661 + list_at(procs, view.selected) 662 + }
+53
test/monitor_test.gleam
··· 1 + import format 2 + import gleam/dict 3 + import gleam/string 4 + import gleeunit 5 + import procfs 6 + 7 + pub fn main() -> Nil { 8 + gleeunit.main() 9 + } 10 + 11 + pub fn human_kb_test() { 12 + let assert "512K" = format.human_kb(512) 13 + let assert True = string.contains(format.human_kb(2048), "M") 14 + let assert True = string.contains(format.human_kb(2_097_152), "G") 15 + } 16 + 17 + pub fn percent_and_meter_test() { 18 + let assert True = string.contains(format.percent(12.3), "%") 19 + let m = format.meter(50.0, 10) 20 + let assert 10 = string.length(m) 21 + let s = format.sparkline([0.0, 25.0, 50.0, 75.0, 100.0], 5) 22 + let assert 5 = string.length(s) 23 + } 24 + 25 + pub fn band_test() { 26 + let assert format.Low = format.band(10.0) 27 + let assert format.Mid = format.band(50.0) 28 + let assert format.High = format.band(75.0) 29 + let assert format.Critical = format.band(95.0) 30 + } 31 + 32 + pub fn ticks_to_time_test() { 33 + // 100 ticks = 1.00s at CLK_TCK=100 34 + let t = procfs.ticks_to_time(100) 35 + let assert True = string.contains(t, "1") 36 + } 37 + 38 + pub fn sample_smoke_test() { 39 + let snap0 = procfs.sample_with_core_history(0, 0, dict.new(), dict.new()) 40 + let assert True = snap0.process_count > 0 41 + let assert True = snap0.mem.total_kb > 0 42 + let assert True = string.length(snap0.hostname) > 0 43 + 44 + let snap1 = 45 + procfs.sample_with_core_history( 46 + snap0.prev_cpu_total, 47 + snap0.prev_cpu_idle, 48 + procfs.core_history_from(snap0), 49 + snap0.prev_proc_ticks, 50 + ) 51 + let assert True = snap1.cpu_total_percent >=. 0.0 52 + let assert True = snap1.cpu_total_percent <=. 100.0 53 + }