Terminal system monitor in Gleam — htop × btop
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}