//// Read Linux /proc for CPU, memory, load, and process info. //// CPU and process percentages need two samples; the first sample //// returns 0% and seeds history for the next refresh. import filepath import gleam/dict.{type Dict} import gleam/float import gleam/int import gleam/list import gleam/result import gleam/string import simplifile pub type CpuCore { CpuCore(name: String, total: Int, idle: Int, percent: Float) } pub type MemInfo { MemInfo( total_kb: Int, available_kb: Int, used_kb: Int, free_kb: Int, buffers_kb: Int, cached_kb: Int, swap_total_kb: Int, swap_free_kb: Int, swap_used_kb: Int, used_percent: Float, swap_percent: Float, ) } pub type Process { Process( pid: Int, user: String, priority: Int, nice: Int, virt_kb: Int, res_kb: Int, state: String, cpu_percent: Float, mem_percent: Float, time_ticks: Int, command: String, /// utime + stime from /proc/[pid]/stat (for delta calc) cpu_ticks: Int, ) } pub type Snapshot { Snapshot( hostname: String, uptime_secs: Float, load1: Float, load5: Float, load15: Float, cores: List(CpuCore), cpu_total_percent: Float, mem: MemInfo, processes: List(Process), process_count: Int, thread_count: Int, /// Aggregate jiffies from the "cpu " line (for next delta) prev_cpu_total: Int, prev_cpu_idle: Int, /// pid -> cpu_ticks for next sample prev_proc_ticks: Dict(Int, Int), ) } const page_kb = 4 const clk_tck = 100 fn read_hostname() -> String { case simplifile.read("/proc/sys/kernel/hostname") { Ok(s) -> string.trim(s) Error(_) -> "localhost" } } fn read_uptime() -> Float { case simplifile.read("/proc/uptime") { Ok(s) -> case string.split(string.trim(s), " ") { [u, ..] -> result.unwrap(float.parse(u), 0.0) _ -> 0.0 } Error(_) -> 0.0 } } fn read_loadavg() -> #(Float, Float, Float) { case simplifile.read("/proc/loadavg") { Ok(s) -> case string.split(string.trim(s), " ") { [a, b, c, ..] -> #( result.unwrap(float.parse(a), 0.0), result.unwrap(float.parse(b), 0.0), result.unwrap(float.parse(c), 0.0), ) _ -> #(0.0, 0.0, 0.0) } Error(_) -> #(0.0, 0.0, 0.0) } } /// Sample system state. Pass previous core totals for accurate per-core CPU %. pub fn sample_with_core_history( prev_cpu_total: Int, prev_cpu_idle: Int, prev_cores: Dict(String, #(Int, Int)), prev_proc_ticks: Dict(Int, Int), ) -> Snapshot { let hostname = read_hostname() let uptime_secs = read_uptime() let #(load1, load5, load15) = read_loadavg() let #(cores, cpu_total, cpu_idle, cpu_total_percent) = case simplifile.read("/proc/stat") { Ok(content) -> { let lines = string.split(content, "\n") |> list.filter(fn(line) { string.starts_with(line, "cpu") }) let core_rows = list.filter_map(lines, fn(line) { case string.split(line, " ") { [name, ..fields] if name != "cpu" -> { let nums = parse_ints(fields) let total = list.fold(nums, 0, int.add) let idle = field_at(nums, 3) + field_at(nums, 4) let pct = case dict.get(prev_cores, name) { Ok(#(pt, pi)) -> cpu_percent(pt, pi, total, idle) Error(_) -> 0.0 } Ok(CpuCore(name: name, total: total, idle: idle, percent: pct)) } _ -> Error(Nil) } }) let #(agg_total, agg_idle) = case lines { [first, ..] -> case string.split(first, " ") { ["cpu", ..fields] -> { let nums = parse_ints(fields) #( list.fold(nums, 0, int.add), field_at(nums, 3) + field_at(nums, 4), ) } _ -> #(0, 0) } _ -> #(0, 0) } let total_pct = cpu_percent(prev_cpu_total, prev_cpu_idle, agg_total, agg_idle) #(core_rows, agg_total, agg_idle, total_pct) } Error(_) -> #([], 0, 0, 0.0) } let mem = read_mem() let #(processes, proc_ticks, threads) = read_processes(prev_cpu_total, cpu_total, prev_proc_ticks, mem.total_kb) Snapshot( hostname: hostname, uptime_secs: uptime_secs, load1: load1, load5: load5, load15: load15, cores: cores, cpu_total_percent: cpu_total_percent, mem: mem, processes: processes, process_count: list.length(processes), thread_count: threads, prev_cpu_total: cpu_total, prev_cpu_idle: cpu_idle, prev_proc_ticks: proc_ticks, ) } pub fn core_history_from(snap: Snapshot) -> Dict(String, #(Int, Int)) { list.fold(snap.cores, dict.new(), fn(acc, c) { dict.insert(acc, c.name, #(c.total, c.idle)) }) } fn cpu_percent( prev_total: Int, prev_idle: Int, total: Int, idle: Int, ) -> Float { let d_total = total - prev_total let d_idle = idle - prev_idle case d_total <= 0 || prev_total == 0 { True -> 0.0 False -> { let busy = d_total - d_idle float.clamp( int.to_float(busy) /. int.to_float(d_total) *. 100.0, 0.0, 100.0, ) } } } fn read_mem() -> MemInfo { case simplifile.read("/proc/meminfo") { Ok(content) -> { let d = parse_meminfo(content) let total = mem_get(d, "MemTotal") let available = mem_get(d, "MemAvailable") let free = mem_get(d, "MemFree") let buffers = mem_get(d, "Buffers") let cached = mem_get(d, "Cached") + mem_get(d, "SReclaimable") let used = int.max(0, total - available) let swap_total = mem_get(d, "SwapTotal") let swap_free = mem_get(d, "SwapFree") let swap_used = int.max(0, swap_total - swap_free) let used_pct = case total { 0 -> 0.0 t -> int.to_float(used) /. int.to_float(t) *. 100.0 } let swap_pct = case swap_total { 0 -> 0.0 t -> int.to_float(swap_used) /. int.to_float(t) *. 100.0 } MemInfo( total_kb: total, available_kb: available, used_kb: used, free_kb: free, buffers_kb: buffers, cached_kb: cached, swap_total_kb: swap_total, swap_free_kb: swap_free, swap_used_kb: swap_used, used_percent: used_pct, swap_percent: swap_pct, ) } Error(_) -> MemInfo(0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0) } } fn parse_meminfo(content: String) -> Dict(String, Int) { string.split(content, "\n") |> list.fold(dict.new(), fn(acc, line) { case string.split(line, ":") { [key, rest] -> { let num = string.trim(rest) |> string.replace(" kB", "") |> string.trim |> int.parse |> result.unwrap(0) dict.insert(acc, string.trim(key), num) } _ -> acc } }) } fn mem_get(d: Dict(String, Int), key: String) -> Int { result.unwrap(dict.get(d, key), 0) } fn read_processes( prev_cpu_total: Int, cpu_total: Int, prev_proc_ticks: Dict(Int, Int), mem_total_kb: Int, ) -> #(List(Process), Dict(Int, Int), Int) { let users = load_users() let pids = list_pids() let delta_cpu = cpu_total - prev_cpu_total let #(procs, ticks, threads) = list.fold(pids, #([], dict.new(), 0), fn(acc, pid) { let #(out, tick_map, thr) = acc case parse_process( pid, users, prev_proc_ticks, delta_cpu, prev_cpu_total, mem_total_kb, ) { Ok(#(proc, n_threads)) -> #( [proc, ..out], dict.insert(tick_map, proc.pid, proc.cpu_ticks), thr + n_threads, ) Error(_) -> acc } }) #(procs, ticks, threads) } fn list_pids() -> List(Int) { case simplifile.read_directory("/proc") { Ok(entries) -> list.filter_map(entries, fn(name) { case int.parse(name) { Ok(pid) -> Ok(pid) Error(_) -> Error(Nil) } }) Error(_) -> [] } } fn parse_process( pid: Int, users: Dict(Int, String), prev_ticks: Dict(Int, Int), delta_cpu: Int, prev_cpu_total: Int, mem_total_kb: Int, ) -> Result(#(Process, Int), Nil) { let base = filepath.join("/proc", int.to_string(pid)) use stat_raw <- result.try(result.replace_error( simplifile.read(base <> "/stat"), Nil, )) use #(state, priority, nice, utime, stime, vsize, rss, num_threads, comm) <- result.try( parse_stat(stat_raw), ) let uid = read_uid(base <> "/status") let user = case dict.get(users, uid) { Ok(u) -> u Error(_) -> int.to_string(uid) } let command = case simplifile.read(base <> "/cmdline") { Ok(cmd) if cmd != "" -> string.replace(cmd, "\u{0000}", " ") |> string.trim _ -> comm } let cpu_ticks = utime + stime let virt_kb = vsize / 1024 let res_kb = rss * page_kb let cpu_pct = case prev_cpu_total == 0 || delta_cpu <= 0 { True -> 0.0 False -> case dict.get(prev_ticks, pid) { Ok(prev) -> { let d = cpu_ticks - prev float.clamp( int.to_float(d) /. int.to_float(delta_cpu) *. 100.0, 0.0, 100.0 *. 64.0, ) } Error(_) -> 0.0 } } let mem_pct = case mem_total_kb { 0 -> 0.0 t -> int.to_float(res_kb) /. int.to_float(t) *. 100.0 } Ok(#( Process( pid: pid, user: user, priority: priority, nice: nice, virt_kb: virt_kb, res_kb: res_kb, state: state, cpu_percent: cpu_pct, mem_percent: mem_pct, time_ticks: cpu_ticks, command: command, cpu_ticks: cpu_ticks, ), num_threads, )) } /// Parse /proc/[pid]/stat — comm is inside parentheses and may contain spaces. fn parse_stat( raw: String, ) -> Result(#(String, Int, Int, Int, Int, Int, Int, Int, String), Nil) { let raw = string.trim(raw) use #(_, rest0) <- result.try(split_once_char(raw, "(")) use #(comm, after) <- result.try(rsplit_once_char(rest0, ")")) let fields = string.trim(after) |> string.split(" ") |> list.filter(fn(s) { s != "" }) // After comm: state(0) ppid(1) ... utime(11) stime(12) ... priority(15) // nice(16) num_threads(17) ... vsize(20) rss(21) use state <- result.try(list_at(fields, 0)) let priority = result.unwrap(int.parse(result.unwrap(list_at(fields, 15), "0")), 0) let nice = result.unwrap(int.parse(result.unwrap(list_at(fields, 16), "0")), 0) let utime = result.unwrap(int.parse(result.unwrap(list_at(fields, 11), "0")), 0) let stime = result.unwrap(int.parse(result.unwrap(list_at(fields, 12), "0")), 0) let num_threads = result.unwrap(int.parse(result.unwrap(list_at(fields, 17), "1")), 1) let vsize = result.unwrap(int.parse(result.unwrap(list_at(fields, 20), "0")), 0) let rss = result.unwrap(int.parse(result.unwrap(list_at(fields, 21), "0")), 0) Ok(#(state, priority, nice, utime, stime, vsize, rss, num_threads, comm)) } fn read_uid(status_path: String) -> Int { case simplifile.read(status_path) { Ok(content) -> { content |> string.split("\n") |> list.find_map(fn(line) { case string.starts_with(line, "Uid:") { True -> { let parts = string.split(string.trim(line), "\t") case parts { [_, uid, ..] -> case int.parse(string.trim(uid)) { Ok(n) -> Ok(n) Error(_) -> { // sometimes spaces not tabs let parts2 = string.split(line, " ") |> list.filter(fn(s) { s != "" }) case parts2 { [_, uid2, ..] -> result.replace_error(int.parse(uid2), Nil) _ -> Error(Nil) } } } _ -> Error(Nil) } } False -> Error(Nil) } }) |> result.unwrap(0) } Error(_) -> 0 } } fn load_users() -> Dict(Int, String) { case simplifile.read("/etc/passwd") { Ok(content) -> string.split(content, "\n") |> list.fold(dict.new(), fn(acc, line) { case string.split(line, ":") { [name, _, uid_s, ..] -> case int.parse(uid_s) { Ok(uid) -> dict.insert(acc, uid, name) Error(_) -> acc } _ -> acc } }) Error(_) -> dict.new() } } pub fn ticks_to_time(ticks: Int) -> String { let total_cs = ticks // centiseconds if clk_tck=100 let total_secs = total_cs / clk_tck let cs = int.remainder(total_cs, clk_tck) |> result.unwrap(0) let hours = total_secs / 3600 let mins = int.remainder(total_secs / 60, 60) |> result.unwrap(0) let secs = int.remainder(total_secs, 60) |> result.unwrap(0) case hours > 0 { True -> int.to_string(hours) <> ":" <> pad2(mins) <> ":" <> pad2(secs) False -> int.to_string(mins) <> ":" <> pad2(secs) <> "." <> pad2(cs) } } fn pad2(n: Int) -> String { case n < 10 { True -> "0" <> int.to_string(n) False -> int.to_string(n) } } fn parse_ints(fields: List(String)) -> List(Int) { list.filter_map(fields, fn(s) { case s == "" { True -> Error(Nil) False -> result.replace_error(int.parse(s), Nil) } }) } fn field_at(nums: List(Int), index: Int) -> Int { result.unwrap(list_at(nums, index), 0) } fn list_at(items: List(a), index: Int) -> Result(a, Nil) { case index < 0 { True -> Error(Nil) False -> { case list.drop(items, index) { [x, ..] -> Ok(x) [] -> Error(Nil) } } } } fn split_once_char(s: String, char: String) -> Result(#(String, String), Nil) { case string.split_once(s, char) { Ok(pair) -> Ok(pair) Error(_) -> Error(Nil) } } fn rsplit_once_char(s: String, char: String) -> Result(#(String, String), Nil) { // Find last occurrence case string.split(s, char) { [] -> Error(Nil) [_] -> Error(Nil) parts -> { let rev = list.reverse(parts) case rev { [last, ..front] -> Ok(#(string.join(list.reverse(front), char), last)) _ -> Error(Nil) } } } }