Terminal system monitor in Gleam — htop × btop
1

Configure Feed

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

monitor / src / procfs.gleam
15 kB 558 lines
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 5import filepath 6import gleam/dict.{type Dict} 7import gleam/float 8import gleam/int 9import gleam/list 10import gleam/result 11import gleam/string 12import simplifile 13 14pub type CpuCore { 15 CpuCore(name: String, total: Int, idle: Int, percent: Float) 16} 17 18pub 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 34pub type Process { 35 Process( 36 pid: Int, 37 ppid: Int, 38 user: String, 39 priority: Int, 40 nice: Int, 41 virt_kb: Int, 42 res_kb: Int, 43 state: String, 44 cpu_percent: Float, 45 mem_percent: Float, 46 time_ticks: Int, 47 command: String, 48 /// utime + stime from /proc/[pid]/stat (for delta calc) 49 cpu_ticks: Int, 50 ) 51} 52 53pub type Snapshot { 54 Snapshot( 55 hostname: String, 56 uptime_secs: Float, 57 load1: Float, 58 load5: Float, 59 load15: Float, 60 cores: List(CpuCore), 61 cpu_total_percent: Float, 62 mem: MemInfo, 63 processes: List(Process), 64 process_count: Int, 65 thread_count: Int, 66 /// Aggregate jiffies from the "cpu " line (for next delta) 67 prev_cpu_total: Int, 68 prev_cpu_idle: Int, 69 /// pid -> cpu_ticks for next sample 70 prev_proc_ticks: Dict(Int, Int), 71 ) 72} 73 74const page_kb = 4 75 76const clk_tck = 100 77 78fn read_hostname() -> String { 79 case simplifile.read("/proc/sys/kernel/hostname") { 80 Ok(s) -> string.trim(s) 81 Error(_) -> "localhost" 82 } 83} 84 85fn read_uptime() -> Float { 86 case simplifile.read("/proc/uptime") { 87 Ok(s) -> 88 case string.split(string.trim(s), " ") { 89 [u, ..] -> result.unwrap(float.parse(u), 0.0) 90 _ -> 0.0 91 } 92 Error(_) -> 0.0 93 } 94} 95 96fn read_loadavg() -> #(Float, Float, Float) { 97 case simplifile.read("/proc/loadavg") { 98 Ok(s) -> 99 case string.split(string.trim(s), " ") { 100 [a, b, c, ..] -> #( 101 result.unwrap(float.parse(a), 0.0), 102 result.unwrap(float.parse(b), 0.0), 103 result.unwrap(float.parse(c), 0.0), 104 ) 105 _ -> #(0.0, 0.0, 0.0) 106 } 107 Error(_) -> #(0.0, 0.0, 0.0) 108 } 109} 110 111/// Sample system state. Pass previous core totals for accurate per-core CPU %. 112pub fn sample_with_core_history( 113 prev_cpu_total: Int, 114 prev_cpu_idle: Int, 115 prev_cores: Dict(String, #(Int, Int)), 116 prev_proc_ticks: Dict(Int, Int), 117) -> Snapshot { 118 let hostname = read_hostname() 119 let uptime_secs = read_uptime() 120 let #(load1, load5, load15) = read_loadavg() 121 122 let #(cores, cpu_total, cpu_idle, cpu_total_percent) = case 123 simplifile.read("/proc/stat") 124 { 125 Ok(content) -> { 126 let lines = 127 string.split(content, "\n") 128 |> list.filter(fn(line) { string.starts_with(line, "cpu") }) 129 130 let core_rows = 131 list.filter_map(lines, fn(line) { 132 case string.split(line, " ") { 133 [name, ..fields] if name != "cpu" -> { 134 let nums = parse_ints(fields) 135 let total = list.fold(nums, 0, int.add) 136 let idle = field_at(nums, 3) + field_at(nums, 4) 137 let pct = case dict.get(prev_cores, name) { 138 Ok(#(pt, pi)) -> cpu_percent(pt, pi, total, idle) 139 Error(_) -> 0.0 140 } 141 Ok(CpuCore(name: name, total: total, idle: idle, percent: pct)) 142 } 143 _ -> Error(Nil) 144 } 145 }) 146 147 let #(agg_total, agg_idle) = case lines { 148 [first, ..] -> 149 case string.split(first, " ") { 150 ["cpu", ..fields] -> { 151 let nums = parse_ints(fields) 152 #( 153 list.fold(nums, 0, int.add), 154 field_at(nums, 3) + field_at(nums, 4), 155 ) 156 } 157 _ -> #(0, 0) 158 } 159 _ -> #(0, 0) 160 } 161 162 let total_pct = 163 cpu_percent(prev_cpu_total, prev_cpu_idle, agg_total, agg_idle) 164 #(core_rows, agg_total, agg_idle, total_pct) 165 } 166 Error(_) -> #([], 0, 0, 0.0) 167 } 168 169 let mem = read_mem() 170 let #(processes, proc_ticks, threads) = 171 read_processes(prev_cpu_total, cpu_total, prev_proc_ticks, mem.total_kb) 172 173 Snapshot( 174 hostname: hostname, 175 uptime_secs: uptime_secs, 176 load1: load1, 177 load5: load5, 178 load15: load15, 179 cores: cores, 180 cpu_total_percent: cpu_total_percent, 181 mem: mem, 182 processes: processes, 183 process_count: list.length(processes), 184 thread_count: threads, 185 prev_cpu_total: cpu_total, 186 prev_cpu_idle: cpu_idle, 187 prev_proc_ticks: proc_ticks, 188 ) 189} 190 191pub fn core_history_from(snap: Snapshot) -> Dict(String, #(Int, Int)) { 192 list.fold(snap.cores, dict.new(), fn(acc, c) { 193 dict.insert(acc, c.name, #(c.total, c.idle)) 194 }) 195} 196 197fn cpu_percent( 198 prev_total: Int, 199 prev_idle: Int, 200 total: Int, 201 idle: Int, 202) -> Float { 203 let d_total = total - prev_total 204 let d_idle = idle - prev_idle 205 case d_total <= 0 || prev_total == 0 { 206 True -> 0.0 207 False -> { 208 let busy = d_total - d_idle 209 float.clamp( 210 int.to_float(busy) /. int.to_float(d_total) *. 100.0, 211 0.0, 212 100.0, 213 ) 214 } 215 } 216} 217 218fn read_mem() -> MemInfo { 219 case simplifile.read("/proc/meminfo") { 220 Ok(content) -> { 221 let d = parse_meminfo(content) 222 let total = mem_get(d, "MemTotal") 223 let available = mem_get(d, "MemAvailable") 224 let free = mem_get(d, "MemFree") 225 let buffers = mem_get(d, "Buffers") 226 let cached = mem_get(d, "Cached") + mem_get(d, "SReclaimable") 227 let used = int.max(0, total - available) 228 let swap_total = mem_get(d, "SwapTotal") 229 let swap_free = mem_get(d, "SwapFree") 230 let swap_used = int.max(0, swap_total - swap_free) 231 let used_pct = case total { 232 0 -> 0.0 233 t -> int.to_float(used) /. int.to_float(t) *. 100.0 234 } 235 let swap_pct = case swap_total { 236 0 -> 0.0 237 t -> int.to_float(swap_used) /. int.to_float(t) *. 100.0 238 } 239 MemInfo( 240 total_kb: total, 241 available_kb: available, 242 used_kb: used, 243 free_kb: free, 244 buffers_kb: buffers, 245 cached_kb: cached, 246 swap_total_kb: swap_total, 247 swap_free_kb: swap_free, 248 swap_used_kb: swap_used, 249 used_percent: used_pct, 250 swap_percent: swap_pct, 251 ) 252 } 253 Error(_) -> MemInfo(0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0) 254 } 255} 256 257fn parse_meminfo(content: String) -> Dict(String, Int) { 258 string.split(content, "\n") 259 |> list.fold(dict.new(), fn(acc, line) { 260 case string.split(line, ":") { 261 [key, rest] -> { 262 let num = 263 string.trim(rest) 264 |> string.replace(" kB", "") 265 |> string.trim 266 |> int.parse 267 |> result.unwrap(0) 268 dict.insert(acc, string.trim(key), num) 269 } 270 _ -> acc 271 } 272 }) 273} 274 275fn mem_get(d: Dict(String, Int), key: String) -> Int { 276 result.unwrap(dict.get(d, key), 0) 277} 278 279fn read_processes( 280 prev_cpu_total: Int, 281 cpu_total: Int, 282 prev_proc_ticks: Dict(Int, Int), 283 mem_total_kb: Int, 284) -> #(List(Process), Dict(Int, Int), Int) { 285 let users = load_users() 286 let pids = list_pids() 287 let delta_cpu = cpu_total - prev_cpu_total 288 289 let #(procs, ticks, threads) = 290 list.fold(pids, #([], dict.new(), 0), fn(acc, pid) { 291 let #(out, tick_map, thr) = acc 292 case 293 parse_process( 294 pid, 295 users, 296 prev_proc_ticks, 297 delta_cpu, 298 prev_cpu_total, 299 mem_total_kb, 300 ) 301 { 302 Ok(#(proc, n_threads)) -> #( 303 [proc, ..out], 304 dict.insert(tick_map, proc.pid, proc.cpu_ticks), 305 thr + n_threads, 306 ) 307 Error(_) -> acc 308 } 309 }) 310 311 #(procs, ticks, threads) 312} 313 314fn list_pids() -> List(Int) { 315 case simplifile.read_directory("/proc") { 316 Ok(entries) -> 317 list.filter_map(entries, fn(name) { 318 case int.parse(name) { 319 Ok(pid) -> Ok(pid) 320 Error(_) -> Error(Nil) 321 } 322 }) 323 Error(_) -> [] 324 } 325} 326 327fn parse_process( 328 pid: Int, 329 users: Dict(Int, String), 330 prev_ticks: Dict(Int, Int), 331 delta_cpu: Int, 332 prev_cpu_total: Int, 333 mem_total_kb: Int, 334) -> Result(#(Process, Int), Nil) { 335 let base = filepath.join("/proc", int.to_string(pid)) 336 use stat_raw <- result.try(result.replace_error( 337 simplifile.read(base <> "/stat"), 338 Nil, 339 )) 340 use #(state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm) <- result.try( 341 parse_stat(stat_raw), 342 ) 343 344 let uid = read_uid(base <> "/status") 345 let user = case dict.get(users, uid) { 346 Ok(u) -> u 347 Error(_) -> int.to_string(uid) 348 } 349 350 let command = case simplifile.read(base <> "/cmdline") { 351 Ok(cmd) if cmd != "" -> 352 string.replace(cmd, "\u{0000}", " ") 353 |> string.trim 354 _ -> comm 355 } 356 357 let cpu_ticks = utime + stime 358 let virt_kb = vsize / 1024 359 let res_kb = rss * page_kb 360 361 let cpu_pct = case prev_cpu_total == 0 || delta_cpu <= 0 { 362 True -> 0.0 363 False -> 364 case dict.get(prev_ticks, pid) { 365 Ok(prev) -> { 366 let d = cpu_ticks - prev 367 float.clamp( 368 int.to_float(d) /. int.to_float(delta_cpu) *. 100.0, 369 0.0, 370 100.0 *. 64.0, 371 ) 372 } 373 Error(_) -> 0.0 374 } 375 } 376 377 let mem_pct = case mem_total_kb { 378 0 -> 0.0 379 t -> int.to_float(res_kb) /. int.to_float(t) *. 100.0 380 } 381 382 Ok(#( 383 Process( 384 pid: pid, 385 ppid: ppid, 386 user: user, 387 priority: priority, 388 nice: nice, 389 virt_kb: virt_kb, 390 res_kb: res_kb, 391 state: state, 392 cpu_percent: cpu_pct, 393 mem_percent: mem_pct, 394 time_ticks: cpu_ticks, 395 command: command, 396 cpu_ticks: cpu_ticks, 397 ), 398 num_threads, 399 )) 400} 401 402/// Parse /proc/[pid]/stat — comm is inside parentheses and may contain spaces. 403/// Returns state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm. 404fn parse_stat( 405 raw: String, 406) -> Result(#(String, Int, Int, Int, Int, Int, Int, Int, Int, String), Nil) { 407 let raw = string.trim(raw) 408 use #(_, rest0) <- result.try(split_once_char(raw, "(")) 409 use #(comm, after) <- result.try(rsplit_once_char(rest0, ")")) 410 let fields = 411 string.trim(after) 412 |> string.split(" ") 413 |> list.filter(fn(s) { s != "" }) 414 415 // After comm: state(0) ppid(1) ... utime(11) stime(12) ... priority(15) 416 // nice(16) num_threads(17) ... vsize(20) rss(21) 417 use state <- result.try(list_at(fields, 0)) 418 let ppid = 419 result.unwrap(int.parse(result.unwrap(list_at(fields, 1), "0")), 0) 420 let priority = 421 result.unwrap(int.parse(result.unwrap(list_at(fields, 15), "0")), 0) 422 let nice = 423 result.unwrap(int.parse(result.unwrap(list_at(fields, 16), "0")), 0) 424 let utime = 425 result.unwrap(int.parse(result.unwrap(list_at(fields, 11), "0")), 0) 426 let stime = 427 result.unwrap(int.parse(result.unwrap(list_at(fields, 12), "0")), 0) 428 let num_threads = 429 result.unwrap(int.parse(result.unwrap(list_at(fields, 17), "1")), 1) 430 let vsize = 431 result.unwrap(int.parse(result.unwrap(list_at(fields, 20), "0")), 0) 432 let rss = result.unwrap(int.parse(result.unwrap(list_at(fields, 21), "0")), 0) 433 434 Ok(#(state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm)) 435} 436 437fn read_uid(status_path: String) -> Int { 438 case simplifile.read(status_path) { 439 Ok(content) -> { 440 content 441 |> string.split("\n") 442 |> list.find_map(fn(line) { 443 case string.starts_with(line, "Uid:") { 444 True -> { 445 let parts = string.split(string.trim(line), "\t") 446 case parts { 447 [_, uid, ..] -> 448 case int.parse(string.trim(uid)) { 449 Ok(n) -> Ok(n) 450 Error(_) -> { 451 // sometimes spaces not tabs 452 let parts2 = 453 string.split(line, " ") 454 |> list.filter(fn(s) { s != "" }) 455 case parts2 { 456 [_, uid2, ..] -> 457 result.replace_error(int.parse(uid2), Nil) 458 _ -> Error(Nil) 459 } 460 } 461 } 462 _ -> Error(Nil) 463 } 464 } 465 False -> Error(Nil) 466 } 467 }) 468 |> result.unwrap(0) 469 } 470 Error(_) -> 0 471 } 472} 473 474fn load_users() -> Dict(Int, String) { 475 case simplifile.read("/etc/passwd") { 476 Ok(content) -> 477 string.split(content, "\n") 478 |> list.fold(dict.new(), fn(acc, line) { 479 case string.split(line, ":") { 480 [name, _, uid_s, ..] -> 481 case int.parse(uid_s) { 482 Ok(uid) -> dict.insert(acc, uid, name) 483 Error(_) -> acc 484 } 485 _ -> acc 486 } 487 }) 488 Error(_) -> dict.new() 489 } 490} 491 492pub fn ticks_to_time(ticks: Int) -> String { 493 let total_cs = ticks 494 // centiseconds if clk_tck=100 495 let total_secs = total_cs / clk_tck 496 let cs = int.remainder(total_cs, clk_tck) |> result.unwrap(0) 497 let hours = total_secs / 3600 498 let mins = int.remainder(total_secs / 60, 60) |> result.unwrap(0) 499 let secs = int.remainder(total_secs, 60) |> result.unwrap(0) 500 case hours > 0 { 501 True -> int.to_string(hours) <> ":" <> pad2(mins) <> ":" <> pad2(secs) 502 False -> int.to_string(mins) <> ":" <> pad2(secs) <> "." <> pad2(cs) 503 } 504} 505 506fn pad2(n: Int) -> String { 507 case n < 10 { 508 True -> "0" <> int.to_string(n) 509 False -> int.to_string(n) 510 } 511} 512 513fn parse_ints(fields: List(String)) -> List(Int) { 514 list.filter_map(fields, fn(s) { 515 case s == "" { 516 True -> Error(Nil) 517 False -> result.replace_error(int.parse(s), Nil) 518 } 519 }) 520} 521 522fn field_at(nums: List(Int), index: Int) -> Int { 523 result.unwrap(list_at(nums, index), 0) 524} 525 526fn list_at(items: List(a), index: Int) -> Result(a, Nil) { 527 case index < 0 { 528 True -> Error(Nil) 529 False -> { 530 case list.drop(items, index) { 531 [x, ..] -> Ok(x) 532 [] -> Error(Nil) 533 } 534 } 535 } 536} 537 538fn split_once_char(s: String, char: String) -> Result(#(String, String), Nil) { 539 case string.split_once(s, char) { 540 Ok(pair) -> Ok(pair) 541 Error(_) -> Error(Nil) 542 } 543} 544 545fn rsplit_once_char(s: String, char: String) -> Result(#(String, String), Nil) { 546 // Find last occurrence 547 case string.split(s, char) { 548 [] -> Error(Nil) 549 [_] -> Error(Nil) 550 parts -> { 551 let rev = list.reverse(parts) 552 case rev { 553 [last, ..front] -> Ok(#(string.join(list.reverse(front), char), last)) 554 _ -> Error(Nil) 555 } 556 } 557 } 558}