Terminal system monitor in Gleam — htop × btop
1

Configure Feed

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

monitor / src / app.gleam
8.3 kB 312 lines
1//// Main application loop: sample /proc, render, handle keys. 2 3import etch/command 4import etch/erlang/input 5import etch/erlang/tty 6import etch/event.{ 7 type Event, type KeyCode, Backspace, Char, DownArrow, End, Enter, Esc, Home, 8 Key, PageDown, PageUp, UpArrow, 9} 10import etch/stdout 11import etch/terminal 12import gleam/dict 13import gleam/erlang/process 14import gleam/int 15import gleam/list 16import gleam/option.{None, Some} 17import gleam/string 18import procfs.{type Snapshot} 19import ui.{ 20 type SortKey, type ViewState, ConfirmKill, Filter, Help, Normal, SortCommand, 21 SortCpu, SortMem, SortPid, SortTime, 22} 23 24const refresh_ms = 1000 25 26@external(erlang, "erlang", "halt") 27fn halt(code: Int) -> Nil 28 29@external(erlang, "os", "cmd") 30fn os_cmd(command: String) -> String 31 32pub 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 63fn 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. 83fn 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 92fn 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 127fn 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 136fn 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 147fn 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 259fn 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 269fn 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 286fn 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 292fn page(view: ViewState) -> Int { 293 int.max(5, view.rows / 2) 294} 295 296fn window_size() -> #(Int, Int) { 297 case tty.window_size() { 298 Ok(#(c, r)) -> #(c, r) 299 Error(_) -> #(100, 30) 300 } 301} 302 303fn 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}