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
14 kB 553 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 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 52pub 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 73const page_kb = 4 74 75const clk_tck = 100 76 77fn read_hostname() -> String { 78 case simplifile.read("/proc/sys/kernel/hostname") { 79 Ok(s) -> string.trim(s) 80 Error(_) -> "localhost" 81 } 82} 83 84fn 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 95fn 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 %. 111pub 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 190pub 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 196fn 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 217fn 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 256fn 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 274fn mem_get(d: Dict(String, Int), key: String) -> Int { 275 result.unwrap(dict.get(d, key), 0) 276} 277 278fn 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 313fn 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 326fn 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. 401fn 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 432fn 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 469fn 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 487pub 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 501fn pad2(n: Int) -> String { 502 case n < 10 { 503 True -> "0" <> int.to_string(n) 504 False -> int.to_string(n) 505 } 506} 507 508fn 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 517fn field_at(nums: List(Int), index: Int) -> Int { 518 result.unwrap(list_at(nums, index), 0) 519} 520 521fn 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 533fn 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 540fn 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}