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 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}