# Sysmon — a live BEAM process monitor built on Harlock.
#
# Run with:
# docker compose run --rm dev sh -c "elixir -r examples/sysmon.exs -S mix run -e 'Harlock.run(Sysmon)'"
#
# Or in IEx:
# docker compose run --rm dev iex -S mix
# iex> c "examples/sysmon.exs"
# iex> Harlock.run(Sysmon)
#
# Keys:
# ↑/↓ or j/k move cursor
# ? help overlay
# q quit (with confirm)
# Esc close overlay
defmodule Sysmon do
use Harlock.App
@refresh_ms 1500
def init(_) do
%{
processes: snapshot(),
cursor: nil,
dialog: nil
}
|> ensure_cursor()
end
def subs(_model), do: [Sub.interval(@refresh_ms, :refresh)]
# ---- Events --------------------------------------------------------------
def update(:refresh, m) do
%{m | processes: snapshot()} |> ensure_cursor()
end
def update({:key, {:char, ??}, []}, m), do: %{m | dialog: :help}
def update({:key, :escape, []}, m), do: %{m | dialog: nil}
def update({:key, {:char, ?q}, []}, %{dialog: nil} = m), do: %{m | dialog: :quit_confirm}
def update({:key, {:char, ?y}, []}, %{dialog: :quit_confirm}), do: :quit
def update({:key, :enter, []}, %{dialog: :quit_confirm}), do: :quit
def update({:key, {:char, ?n}, []}, %{dialog: :quit_confirm} = m), do: %{m | dialog: nil}
def update({:key, :down, []}, m), do: move_cursor(m, 1)
def update({:key, {:char, ?j}, []}, m), do: move_cursor(m, 1)
def update({:key, :up, []}, m), do: move_cursor(m, -1)
def update({:key, {:char, ?k}, []}, m), do: move_cursor(m, -1)
def update(_, m), do: m
# ---- View ----------------------------------------------------------------
def view(m) do
base =
vbox(
constraints: [length: 1, length: 1, fill: 1, length: 1],
children: [
text("Sysmon — BEAM process monitor", style: [bold: true]),
text(
"#{length(m.processes)} processes · refresh #{@refresh_ms}ms · " <>
"?=help q=quit ↑↓/jk=move",
style: [dim: true]
),
process_table(m),
status(m)
]
)
case m.dialog do
nil -> base
:help -> with_help(base)
:quit_confirm -> with_quit_confirm(base)
end
end
defp process_table(m) do
table(
columns: [
column(title: " PID", width: {:length, 13}, render: fn p -> p.pid_str end),
column(
title: "Name / initial call",
width: {:fill, 2},
render: fn p -> p.name end
),
column(
title: "Reds",
width: {:length, 12},
align: :right,
render: fn p -> human_int(p.reductions) end
),
column(
title: "Mem",
width: {:length, 10},
align: :right,
render: fn p -> human_bytes(p.memory) end
),
column(
title: "Q",
width: {:length, 6},
align: :right,
render: fn p -> Integer.to_string(p.queue) end
)
],
rows: m.processes,
row_id: & &1.pid_str,
focused_row: m.cursor,
focusable: :process_table
)
end
defp status(_m) do
{total, _} = :erlang.statistics(:wall_clock)
text("uptime: #{format_uptime(total)}", style: [dim: true])
end
defp with_help(base) do
overlay(
child: base,
over:
vbox(
focus_trap: true,
constraints: [length: 1, length: 1, length: 1, length: 1, length: 1, length: 1, fill: 1, length: 1],
children: [
text(" Help", style: [bold: true]),
text(" ────"),
text(" ? toggle this help"),
text(" q quit (with confirm)"),
text(" ↑↓ jk move cursor"),
text(" Esc close overlay"),
spacer(),
text(" press Esc to close", style: [dim: true])
]
),
width: 40,
height: 10,
anchor: :center,
focus_trap: true
)
end
defp with_quit_confirm(base) do
overlay(
child: base,
over:
vbox(
focus_trap: true,
constraints: [length: 1, length: 1, fill: 1, length: 1],
children: [
text(" Quit?", style: [bold: true]),
text(" ─────"),
text(" press y/Enter to quit, n/Esc to cancel"),
spacer()
]
),
width: 44,
height: 6,
anchor: :center,
focus_trap: true
)
end
# ---- Cursor + data -------------------------------------------------------
defp ensure_cursor(%{processes: [], cursor: _} = m), do: %{m | cursor: nil}
defp ensure_cursor(%{processes: ps, cursor: nil} = m),
do: %{m | cursor: hd(ps).pid_str}
defp ensure_cursor(%{processes: ps, cursor: c} = m) do
if Enum.any?(ps, &(&1.pid_str == c)),
do: m,
else: %{m | cursor: hd(ps).pid_str}
end
defp move_cursor(%{processes: []} = m, _), do: m
defp move_cursor(m, delta) do
idx = Enum.find_index(m.processes, &(&1.pid_str == m.cursor)) || 0
new_idx = max(0, min(length(m.processes) - 1, idx + delta))
%{m | cursor: Enum.at(m.processes, new_idx).pid_str}
end
defp snapshot do
Process.list()
|> Enum.map(&info/1)
|> Enum.reject(&is_nil/1)
|> Enum.sort_by(& &1.memory, :desc)
end
defp info(pid) do
case Process.info(pid, [:registered_name, :memory, :reductions, :message_queue_len, :initial_call]) do
nil ->
nil
info ->
%{
pid_str: :erlang.pid_to_list(pid) |> List.to_string(),
name: name_for(info),
memory: info[:memory] || 0,
reductions: info[:reductions] || 0,
queue: info[:message_queue_len] || 0
}
end
end
defp name_for(info) do
case info[:registered_name] do
[] -> initial_call_str(info[:initial_call])
nil -> initial_call_str(info[:initial_call])
name when is_atom(name) -> Atom.to_string(name)
end
end
defp initial_call_str({m, f, a}), do: "#{inspect(m)}.#{f}/#{a}"
defp initial_call_str(_), do: "(unknown)"
# ---- Formatting ----------------------------------------------------------
defp human_bytes(n) when n < 1024, do: "#{n} B"
defp human_bytes(n) when n < 1024 * 1024, do: "#{Float.round(n / 1024, 1)} KB"
defp human_bytes(n) when n < 1024 * 1024 * 1024, do: "#{Float.round(n / 1_048_576, 1)} MB"
defp human_bytes(n), do: "#{Float.round(n / 1_073_741_824, 1)} GB"
defp human_int(n) when n < 1000, do: Integer.to_string(n)
defp human_int(n) when n < 1_000_000, do: "#{Float.round(n / 1000, 1)}K"
defp human_int(n) when n < 1_000_000_000, do: "#{Float.round(n / 1_000_000, 1)}M"
defp human_int(n), do: "#{Float.round(n / 1_000_000_000, 1)}G"
defp format_uptime(ms) do
secs = div(ms, 1000)
minutes = div(secs, 60)
hours = div(minutes, 60)
cond do
hours > 0 -> "#{hours}h#{rem(minutes, 60)}m"
minutes > 0 -> "#{minutes}m#{rem(secs, 60)}s"
true -> "#{secs}s"
end
end
end
case System.argv() do
["--run"] -> Harlock.run(Sysmon)
_ -> :ok
end