agentmon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/exe/agentmon +18 -0
  6. data/lib/agentmon/commands/cli_options.rb +21 -0
  7. data/lib/agentmon/commands/footprint.rb +81 -0
  8. data/lib/agentmon/commands/memory.rb +110 -0
  9. data/lib/agentmon/commands/record.rb +118 -0
  10. data/lib/agentmon/commands/report.rb +127 -0
  11. data/lib/agentmon/commands/sessions.rb +98 -0
  12. data/lib/agentmon/commands/top.rb +87 -0
  13. data/lib/agentmon/commands/tree.rb +96 -0
  14. data/lib/agentmon/darwin.rb +235 -0
  15. data/lib/agentmon/engine.rb +115 -0
  16. data/lib/agentmon/focus.rb +88 -0
  17. data/lib/agentmon/metrics/memory.rb +76 -0
  18. data/lib/agentmon/metrics/network.rb +245 -0
  19. data/lib/agentmon/metrics/pressure_drivers.rb +57 -0
  20. data/lib/agentmon/metrics/process_rates.rb +65 -0
  21. data/lib/agentmon/metrics/process_rows.rb +41 -0
  22. data/lib/agentmon/metrics/session_ledger.rb +160 -0
  23. data/lib/agentmon/metrics/session_memory.rb +84 -0
  24. data/lib/agentmon/metrics/session_names.rb +169 -0
  25. data/lib/agentmon/metrics/sessions.rb +80 -0
  26. data/lib/agentmon/metrics/system.rb +114 -0
  27. data/lib/agentmon/model.rb +263 -0
  28. data/lib/agentmon/probes/cwd.rb +32 -0
  29. data/lib/agentmon/probes/memory.rb +139 -0
  30. data/lib/agentmon/probes/network.rb +326 -0
  31. data/lib/agentmon/probes/processes.rb +72 -0
  32. data/lib/agentmon/probes/system.rb +134 -0
  33. data/lib/agentmon/program.rb +55 -0
  34. data/lib/agentmon/reading.rb +83 -0
  35. data/lib/agentmon/recorders/memory.rb +19 -0
  36. data/lib/agentmon/recorders/sessions.rb +40 -0
  37. data/lib/agentmon/registry.rb +155 -0
  38. data/lib/agentmon/sampler.rb +50 -0
  39. data/lib/agentmon/store.rb +86 -0
  40. data/lib/agentmon/ui/connections.rb +75 -0
  41. data/lib/agentmon/ui/interaction.rb +94 -0
  42. data/lib/agentmon/ui/memory.rb +125 -0
  43. data/lib/agentmon/ui/process_actions.rb +49 -0
  44. data/lib/agentmon/ui/process_detail.rb +148 -0
  45. data/lib/agentmon/ui/process_network.rb +13 -0
  46. data/lib/agentmon/ui/process_scopes.rb +32 -0
  47. data/lib/agentmon/ui/process_waits.rb +99 -0
  48. data/lib/agentmon/ui/processes.rb +42 -0
  49. data/lib/agentmon/ui/session_focus.rb +126 -0
  50. data/lib/agentmon/ui/session_memory.rb +78 -0
  51. data/lib/agentmon/ui/sessions.rb +130 -0
  52. data/lib/agentmon/ui/theme.rb +49 -0
  53. data/lib/agentmon/ui.rb +80 -0
  54. data/lib/agentmon/version.rb +5 -0
  55. data/lib/agentmon/views/dense.rb +157 -0
  56. data/lib/agentmon/views/history.rb +62 -0
  57. data/lib/agentmon/views/signals.rb +279 -0
  58. data/lib/agentmon/views/visual.rb +172 -0
  59. data/lib/agentmon/views/widgets.rb +352 -0
  60. data/lib/agentmon/views.rb +875 -0
  61. data/lib/agentmon.rb +38 -0
  62. metadata +135 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The dashboard's look and the terminal window title.
4
+ #
5
+ # Theme (r2ui's `theme`, lipgloss colours): panel titles and the focused border in agentmon's
6
+ # orange accent, which also draws session sparklines and the active scope ("[Agents]"); memory
7
+ # pressure (gauges, percent cells) goes green under 70%, amber, then red from 90%.
8
+ #
9
+ # Window title, kept current as sessions start and end and pressure moves:
10
+ #
11
+ # agentmon · 3 sessions · 42% pressure
12
+ # agentmon · 1 session (pressure left out until memory data is available)
13
+ module Agentmon
14
+ module UI
15
+ # The title text for a Reading (nil before the first one).
16
+ module ThemeTitle
17
+ ACCENT = "#D97757"
18
+
19
+ module_function
20
+
21
+ def title(reading)
22
+ parts = ["agentmon"]
23
+ return parts.first unless reading
24
+
25
+ alive = (reading[:session_ledger] || []).count { |s| s.ended_at.nil? }
26
+ parts << "#{alive} #{alive == 1 ? "session" : "sessions"}"
27
+ pressure = reading[:memory]&.pressure
28
+ parts << "#{pressure.round}% pressure" if pressure
29
+ parts.join(" · ")
30
+ end
31
+ end
32
+ end
33
+
34
+ dashboard do |engine|
35
+ theme do
36
+ title foreground: UI::ThemeTitle::ACCENT, bold: true
37
+ focus foreground: UI::ThemeTitle::ACCENT
38
+ accent foreground: UI::ThemeTitle::ACCENT
39
+ selected reverse: true, foreground: UI::ThemeTitle::ACCENT
40
+ ok foreground: "#5FAF5F"
41
+ warn foreground: "#E5A50A"
42
+ alert foreground: "#E5484D", bold: true
43
+ end
44
+
45
+ # Runs after every update; reads the latest reading without sampling (the panels' feeds keep it
46
+ # fresh), so a keypress never waits on `ps`.
47
+ window_title { UI::ThemeTitle.title(engine.current(max_age: Float::INFINITY)) }
48
+ end
49
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentmon
4
+ # Builds the r2ui dashboard from the registered pieces: each resource (its definition, then its
5
+ # extensions), the dashboard-level blocks, then the rows with their panels in order. Every block
6
+ # gets the Engine, so sources read `engine.current[...]`.
7
+ #
8
+ # Agentmon::UI.install(engine:) # into R2UI.registry, as the :agentmon dashboard
9
+ # R2UI.snapshot(:agentmon, width: 140, height: 40)
10
+ module UI
11
+ DASHBOARD = :agentmon
12
+ # The panel that has key focus when the dashboard opens.
13
+ FOCUS = :process
14
+
15
+ module_function
16
+
17
+ # `view:` (a registered Agentmon.view name) builds the dashboard from that view's rows instead
18
+ # of the registry layout (docs/views.md); resources then get the views' decorations.
19
+ def install(engine:, view: nil, registry: Agentmon.registry, into: R2UI.registry)
20
+ spec = view && (registry[:view, view] or raise Error, "no layout #{view} (#{layouts(registry).join(", ")})")
21
+ Views.installing = spec
22
+ registry.resources.each do |name, blocks|
23
+ into.add_resource(R2UI::DSL::Resource.build(name) { blocks.each { |b| instance_exec(engine, &b) } })
24
+ end
25
+ if spec
26
+ into.add_resource(Views.signals_resource(engine))
27
+ into.add_dashboard(Views.dashboard(spec, engine, registry))
28
+ else
29
+ into.add_dashboard(build_dashboard(engine, registry))
30
+ end
31
+ ensure
32
+ Views.installing = nil
33
+ end
34
+
35
+ # The registered view names, for --layout.
36
+ def layouts(registry = Agentmon.registry) = registry.all(:view).map(&:name)
37
+
38
+ def build_dashboard(engine, registry)
39
+ layout = registry.layout
40
+ extras = registry.dashboard_blocks
41
+ R2UI::DSL::Dashboard.build(DASHBOARD) do
42
+ title "agentmon"
43
+ extras.each { |b| instance_exec(engine, &b) }
44
+ layout.each do |spec, panels|
45
+ row(height: spec.height) do
46
+ panels.each do |p|
47
+ items = p.block && proc { instance_exec(engine, &p.block) }
48
+ panel(p.name, resource: p.resource, span: p.span, title: p.title, **p.options, &items)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ # A broken probe, metric or recorder shows on the left of the status bar ("⚠ metric memory:
57
+ # NoMethodError: ..."), instead of the table's sort line, until it recovers. r2ui's `status` hook
58
+ # is process-wide, so it checks that the agentmon dashboard is the one drawing.
59
+ R2UI.extension :agentmon_errors do
60
+ status do
61
+ problems = dashboard.name == UI::DASHBOARD ? Agentmon.engine_errors : {}
62
+ (name, message = problems.first) && "⚠ #{name}: #{message}#{" (+#{problems.size - 1} more)" if problems.size > 1}"
63
+ end
64
+ end
65
+
66
+ # The dashboard opens with keys on the Processes panel, whatever panels come before it (r2ui
67
+ # otherwise focuses the first panel with a table). Runs in App.new, so snapshots see it too.
68
+ R2UI.extension :agentmon_focus do
69
+ setup do
70
+ next unless dashboard.name == UI::DASHBOARD
71
+
72
+ panel = dashboard.panels.find { |p| p.name == UI::FOCUS }
73
+ app.focus = panel if panel
74
+ end
75
+ end
76
+
77
+ # The rows panels go in. Stories add panels to these (or register rows of their own).
78
+ row :top, order: 10, height: 14
79
+ row :main, order: 100
80
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentmon
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # dense: htop-like, sessions first (docs/views.md). Home is the machine strip, every agent session
4
+ # (by name when Claude Code or Codex knows one) with its CPU, footprint, disk and network, and all
5
+ # agents together; Enter on a session drills into it (its CPU, memory and network strip, its
6
+ # processes, connections and the process detail; Escape returns). Its real home frame, from
7
+ # `COLUMNS=100 LINES=50 bundle exec exe/agentmon --layout dense | cat` (one sample, so sparklines
8
+ # and areas are still empty and network rates still settling):
9
+ #
10
+ # ╭─ CPU ──────────────────────────────────╮╭─ Memory ─────────────────╮╭─ I/O ──────────────────────╮
11
+ # │CPU ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 38.2%││used ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 25G││net in ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 843B/s│
12
+ # │user ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 25.3%││pressure ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤ 48.0%││net out ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 1.1K/s│
13
+ # │load 1m 5.5 load 5m 6.2││swap ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 255M││disk rd ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 0B/s│
14
+ # │load 15m 6.0 cores 10││compr ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 11G││disk wr ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 209K/s│
15
+ # ╰────────────────────────────────────────╯╰──────────────────────────╯╰────────────────────────────╯
16
+ # ╭─ Sessions ─[Live] All ───────────────────────────────────────────────────────────────────────────╮
17
+ # │ Session Procs CPU▼ Footprint Read Write Net in Net out│
18
+ # │ Claude 21354 46 ⠀⠀⠀⠀⢀ 41.1% 3.8G 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
19
+ # │ Agentmon r2ui… 5 ⠀⠀⠀⠀⢀ 13.7% 372M 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
20
+ # │ ChatGPT 2388 18 ⠀⠀⠀⠀⢀ 9.2% 3.3G 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
21
+ # │ Project history… 1 ⠀⠀⠀⠀⢀ 0.4% 285M 0B/s 0B/s ⠀⠀⠀⠀⢀ │
22
+ # │ EPK component review… 1 ⠀⠀⠀⠀⢀ 0.2% 262M 0B/s 0B/s ⠀⠀⠀⠀⢀ │
23
+ # │ Weekly session themes… 1 ⠀⠀⠀⠀⢀ 0.2% 181M 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
24
+ # │ Mods in Desktop vs… 1 ⠀⠀⠀⠀⢀ 0.1% 226M 0B/s 0B/s ⠀⠀⠀⠀⢀ │
25
+ # │ Set up a Homebrew tap… 1 ⠀⠀⠀⠀⢀ 0.1% 200M 0B/s 0B/s ⠀⠀⠀⠀⢀ │
26
+ # │ Build Magrathea local… 25 ⠀⠀⠀⠀⢀ 0.0% 1022M 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
27
+ # │ codex 3064 · Resources 4 ⠀⠀⠀⠀⢀ 0.0% 76M 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
28
+ # │ │
29
+ # ... 19 more empty table lines ...
30
+ # ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
31
+ # ╭─ All agents ───────────────────────────────────╮╭─ Agents network ───────────────────────────────╮
32
+ # │procs 103 resident 9.6G││hosts 19 conns 65│
33
+ # │CPU 6.5%││net in 0B/s│
34
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
35
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
36
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
37
+ # │footprint 9.6G││net out 0B/s│
38
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
39
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
40
+ # ╰────────────────────────────────────────────────╯╰────────────────────────────────────────────────╯
41
+ # sessions · ⏎ open a session
42
+ #
43
+ # Drilled into "Agentmon r2ui integration" (a pty at 100×50: `/` integration, Enter). The Memory
44
+ # panel's RAM meter is the machine (25G of 32G used) with this session's footprint as the accent
45
+ # slice at the left, the rest of used memory dim and free memory empty:
46
+ #
47
+ # ╭─ CPU ─────────────────────────╮╭─ Memory ──────────────────────╮╭─ Network ──────────────────────╮
48
+ # │CPU ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀ 0.2%││RAM ▕▏████▌ ▏ 324M 1% · 25G/32G││net in ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣸⣄⣀⣀ 0B/s│
49
+ # │disk rd ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀ 0B/s││pressure 47.0%││net out ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣇⣀⣀ 0B/s│
50
+ # │disk wr ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀ 0B/s││resident 439M││received 1.1K│
51
+ # │procs 3││wired 0B││sent 51K│
52
+ # │cores 10││growth +9.6K/s││hosts 2│
53
+ # ╰───────────────────────────────╯╰───────────────────────────────╯╰────────────────────────────────╯
54
+ # ╭─ Processes ─[Agents] All Busy Heavy Writing Waiting ─────────────────────────────────────────╮
55
+ # │ Pid Name CPU▼ Footprint Wait Read Write Net in Net out │
56
+ # │ 22602 claude 1.6% 321M 1.6% 0B/s 0B/s 0B/s 0B/s │
57
+ # │▴ 6766 sleep 0.0% 832K 0.0% 0B/s 0B/s 0B/s 0B/s │
58
+ # │▾ 6543 zsh 0.0% 2.5M 0.0% 0B/s 0B/s 0B/s 0B/s │
59
+ # │ │
60
+ # ... 19 more empty table lines ...
61
+ # ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
62
+ # ╭─ Connections ────────────────────────────────────────────────────────────────────────────────────╮
63
+ # │ Process Remote State In Out▼ Recv Sent│
64
+ # │ claude 22602 160.79.104.10:443 Established 0B/s 0B/s 39K 2.9M│
65
+ # │ claude 22602 160.79.104.10:443 Established 0B/s 0B/s 918K 4.5M│
66
+ # │ claude 22602 160.79.104.10:443 Established 0B/s 0B/s 234K 540K│
67
+ # │ claude 22602 ….bc.googleusercontent.com:443 Established 0B/s 0B/s 26K 8.3M│
68
+ # │ claude 22602 160.79.104.10:443 Established 0B/s 0B/s 13K 25K│
69
+ # │ claude 22602 160.79.104.10:443 Established 0B/s 0B/s 3.2K 4.8K│
70
+ # ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
71
+ # ╭─ Detail ─────────────────────────────────────────────────────────────────────────────────────────╮
72
+ # │claude pid 22602 │
73
+ # │Command /Users/ryan… Resident 435M Started 3h 2m ago │
74
+ # │Directory ~/The Source… Peak 368M Open files 21 │
75
+ # │Session Agentmon r2ui… CPU time 4m 30s Threads 24 (0 running) │
76
+ # │Footprint 321M Written 109M CPU wait 1.6% now, 3m 23s… │
77
+ # ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
78
+ # ▸ Agentmon r2ui integration · claude 22602 · agentmon · busy · esc back to sessions
79
+ #
80
+ # Sessions has no Age column, and its label (the session's name first) is 23 wide with Procs at
81
+ # 5: one cell more pushes Write off at 100 columns. The drilled-in Processes table leaves the sort
82
+ # sparkline off so all nine columns fit.
83
+ module Agentmon
84
+ view :dense, title: "agentmon" do
85
+ # Home: the machine strip, every agent session with its breakdown, all agents together.
86
+ row height: 6 do
87
+ panel :cpu, title: "CPU", span: 3 do
88
+ spark "system.cpu"
89
+ spark "system.user"
90
+ stat "system.load1", "system.load5", "system.load15", "system.ncpu", columns: 2
91
+ end
92
+ panel :memory, title: "Memory", span: 2 do
93
+ spark "memory.used"
94
+ spark "memory.pressure"
95
+ spark "memory.swap_used"
96
+ spark "memory.compressed", label: "compr"
97
+ end
98
+ panel :io, title: "I/O", span: 2 do
99
+ spark "system.net_in_rate", label: "net in"
100
+ spark "system.net_out_rate", label: "net out"
101
+ spark "system.disk_read_rate", label: "disk rd"
102
+ spark "system.disk_write_rate", label: "disk wr"
103
+ end
104
+ end
105
+ row do
106
+ top :session, title: "Sessions", by: :cpu, spark: %i[cpu net_out_rate],
107
+ columns: %i[label processes cpu footprint read_rate write_rate net_in_rate net_out_rate],
108
+ widths: { label: 23, processes: 5 }
109
+ end
110
+ row height: 10 do
111
+ panel :agents, title: "All agents" do
112
+ stat "focus.processes", "focus.resident", columns: 2
113
+ trend "focus.cpu", "focus.footprint", height: nil
114
+ end
115
+ panel :agents_io, title: "Agents network" do
116
+ stat "focus.remote_hosts", "focus.connections", columns: 2
117
+ trend "focus.net_in_rate", "focus.net_out_rate", height: nil
118
+ end
119
+ end
120
+
121
+ # Drilled into one session (Enter on Sessions, F on a process; Escape returns).
122
+ focused do
123
+ row height: 7 do
124
+ panel :s_cpu, title: "CPU" do
125
+ spark "focus.cpu"
126
+ spark "focus.read_rate", label: "disk rd"
127
+ spark "focus.write_rate", label: "disk wr"
128
+ stat "focus.processes", "system.ncpu", columns: 2
129
+ end
130
+ panel :s_memory, title: "Memory" do
131
+ # The machine's RAM with the session as the accent slice; pressure as a stat (a second
132
+ # meter's "pressure" label would leave the RAM bar under 6 cells at 100 columns).
133
+ meter "memory.used", part: "focus.footprint", label: "RAM"
134
+ stat "memory.pressure", "focus.resident", "focus.wired", "focus.growth_rate", "focus.pagein_rate",
135
+ columns: 2
136
+ end
137
+ panel :s_net, title: "Network" do
138
+ spark "focus.net_in_rate"
139
+ spark "focus.net_out_rate"
140
+ stat "focus.bytes_in", "focus.bytes_out", "focus.remote_hosts", "focus.connections", columns: 2
141
+ end
142
+ end
143
+ row do
144
+ top :process, title: "Processes", by: :cpu, spark: false,
145
+ columns: %i[pid name cpu footprint run_wait read_rate write_rate net_in_rate net_out_rate],
146
+ widths: { name: 22 }
147
+ end
148
+ row height: 9 do
149
+ top :connection, title: "Connections", by: :out_rate, spark: false, widths: { process: 18 },
150
+ columns: %i[process protocol remote_text state in_rate out_rate bytes_in bytes_out]
151
+ end
152
+ row height: 7 do
153
+ detail :process, columns: 3
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentmon
4
+ module Views
5
+ # The last CAPACITY values of every signal a view draws (docs/views.md, "History"), appended
6
+ # once per Reading: `record` ignores a second call with the same `at` for a key, so a view
7
+ # drawn many times between samples (animation frames) appends each sample once.
8
+ #
9
+ # history.record("memory.used", reading.at, value)
10
+ # history["memory.used"] # => [..., 23.1e9], oldest first (frozen)
11
+ #
12
+ # Unknown (nil) values are not appended, but their `at` still counts as seen. Meant for one
13
+ # drawing thread: no lock.
14
+ class History
15
+ CAPACITY = 120 # four minutes at 2 s
16
+
17
+ attr_reader :capacity
18
+
19
+ def initialize(capacity: CAPACITY)
20
+ raise ArgumentError, "capacity must be a positive Integer" unless capacity.is_a?(Integer) && capacity.positive?
21
+
22
+ @capacity = capacity
23
+ @series = {}
24
+ @last_at = {}
25
+ end
26
+
27
+ # Appends value.to_f to key's series unless `at` was already recorded for it; returns the
28
+ # series (frozen).
29
+ def record(key, at, value)
30
+ if !@last_at.key?(key) || @last_at[key] != at
31
+ @last_at[key] = at
32
+ append(key, value.to_f) unless value.nil?
33
+ end
34
+ self[key]
35
+ end
36
+
37
+ # The series for `key`, oldest first: a frozen Array, [] when unknown.
38
+ def [](key) = @series.fetch(key) { EMPTY }
39
+
40
+ def keys = @last_at.keys
41
+
42
+ def forget(key)
43
+ @last_at.delete(key)
44
+ @series.delete(key)
45
+ nil
46
+ end
47
+
48
+ EMPTY = [].freeze
49
+ private_constant :EMPTY
50
+
51
+ private
52
+
53
+ # Keeps each stored series frozen: a new Array per append, so a series handed out earlier
54
+ # never changes under its holder.
55
+ def append(key, value)
56
+ series = [*@series[key], value]
57
+ series.shift(series.size - capacity) if series.size > capacity
58
+ @series[key] = series.freeze
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentmon
4
+ module Views
5
+ # A named number on the machine with a unit (docs/views.md, "Vocabulary"): "system.cpu" is
6
+ # reading[:system].cpu. `max` is its natural maximum (percent: 100), `of` another signal's name
7
+ # that is its whole ("memory.used" of "memory.total"). Either may be nil.
8
+ Signal = Data.define(:name, :metric, :member, :label, :unit, :max, :of)
9
+
10
+ # Signals: parse a name into a Signal (defaults, then inference from the member name, then
11
+ # overrides), read its value from a Reading (nil is unknown, never 0) and format it.
12
+ #
13
+ # s = Signals.parse("memory.used") # label "used", unit :bytes, of "memory.total"
14
+ # Signals.value(s, reading) # => 23.1e9, or nil
15
+ # Signals.fraction(s, reading) # => 0.71 (value / total, or / max), or nil
16
+ # Signals.format(Signals.value(s, reading), s.unit) # => "21G"
17
+ #
18
+ # The virtual metric :focus is the focused session, else every agent session together
19
+ # (Signals::Focus.value).
20
+ module Signals
21
+ PART = /\A[a-z_][a-z0-9_]*\z/
22
+ EVENT_STEMS = %w[pagein fault cow_fault context_switch].freeze
23
+ PERCENT_MEMBERS = %w[cpu pressure share user system run_wait].freeze
24
+ INTEGER_MEMBERS = %w[processes threads ncpu count].freeze
25
+
26
+ def self.entry(label, unit, max: nil, of: nil) = { label:, unit:, max:, of: }.freeze
27
+ private_class_method :entry
28
+
29
+ DEFAULTS = {
30
+ "system.cpu" => entry("CPU", :percent, max: 100),
31
+ "system.user" => entry("user", :percent, max: 100),
32
+ "system.system" => entry("system", :percent, max: 100),
33
+ "system.ncpu" => entry("cores", :integer),
34
+ "system.load1" => entry("load 1m", :number),
35
+ "system.load5" => entry("load 5m", :number),
36
+ "system.load15" => entry("load 15m", :number),
37
+ "system.net_in_rate" => entry("net in", :bytes_per_sec),
38
+ "system.net_out_rate" => entry("net out", :bytes_per_sec),
39
+ "system.disk_read_rate" => entry("disk read", :bytes_per_sec),
40
+ "system.disk_write_rate" => entry("disk write", :bytes_per_sec),
41
+ "memory.used" => entry("used", :bytes, of: "memory.total"),
42
+ "memory.total" => entry("total", :bytes),
43
+ "memory.app" => entry("app", :bytes),
44
+ "memory.wired" => entry("wired", :bytes),
45
+ "memory.compressed" => entry("compressed", :bytes),
46
+ "memory.cached" => entry("cached", :bytes),
47
+ "memory.free" => entry("free", :bytes),
48
+ "memory.swap_used" => entry("swap", :bytes, of: "memory.swap_total"),
49
+ "memory.swap_total" => entry("swap total", :bytes),
50
+ "memory.compression_ratio" => entry("ratio", :ratio),
51
+ "memory.pressure" => entry("pressure", :percent, max: 100),
52
+ "memory.swapin_rate" => entry("swap in", :bytes_per_sec),
53
+ "memory.swapout_rate" => entry("swap out", :bytes_per_sec),
54
+ "memory.compression_rate" => entry("compress", :bytes_per_sec),
55
+ "memory.decompression_rate" => entry("decompress", :bytes_per_sec),
56
+ "focus.cpu" => entry("CPU", :percent, max: 100),
57
+ "focus.footprint" => entry("footprint", :bytes, of: "memory.used"),
58
+ "focus.resident" => entry("resident", :bytes),
59
+ "focus.wired" => entry("wired", :bytes),
60
+ "focus.peak_footprint" => entry("peak", :bytes),
61
+ "focus.growth_rate" => entry("growth", :signed_bytes_per_sec),
62
+ "focus.pagein_rate" => entry("pageins", :per_sec),
63
+ "focus.processes" => entry("procs", :integer),
64
+ "focus.read_rate" => entry("read", :bytes_per_sec),
65
+ "focus.write_rate" => entry("write", :bytes_per_sec),
66
+ "focus.net_in_rate" => entry("net in", :bytes_per_sec),
67
+ "focus.net_out_rate" => entry("net out", :bytes_per_sec),
68
+ "focus.bytes_in" => entry("received", :bytes),
69
+ "focus.bytes_out" => entry("sent", :bytes),
70
+ "focus.connections" => entry("conns", :integer),
71
+ "focus.remote_hosts" => entry("hosts", :integer)
72
+ }.freeze
73
+
74
+ module_function
75
+
76
+ # A Signal for "metric.member"; ArgumentError when the name isn't one.
77
+ def parse(name, label: nil, unit: nil, max: nil, of: nil)
78
+ name = name.to_s
79
+ parts = name.split(".", -1)
80
+ unless parts.size == 2 && parts.all? { |part| PART.match?(part) }
81
+ raise ArgumentError, "bad signal #{name.inspect}: expected \"metric.member\" (e.g. \"system.cpu\")"
82
+ end
83
+
84
+ metric, member = parts
85
+ defaults = DEFAULTS.fetch(name, {})
86
+ inferred = infer(member)
87
+ pick = ->(key, override) { override.nil? ? (defaults[key].nil? ? inferred[key] : defaults[key]) : override }
88
+ Signal.new(name:, metric: metric.to_sym, member: member.to_sym, label: pick[:label, label],
89
+ unit: pick[:unit, unit]&.to_sym, max: pick[:max, max], of: pick[:of, of]&.to_s)
90
+ end
91
+
92
+ # Label, unit and max guessed from a member name.
93
+ def infer(member)
94
+ member = member.to_s
95
+ rate = member.end_with?("_rate")
96
+ stem = rate ? member.delete_suffix("_rate") : member
97
+ label = stem.tr("_", " ")
98
+ unit, max =
99
+ if rate
100
+ [EVENT_STEMS.include?(stem) ? :per_sec : :bytes_per_sec, nil]
101
+ elsif PERCENT_MEMBERS.include?(member) || member.end_with?("_pct", "_percent")
102
+ [:percent, 100]
103
+ elsif member.start_with?("load")
104
+ [:number, nil]
105
+ elsif INTEGER_MEMBERS.include?(member) || member.end_with?("_count")
106
+ [:integer, nil]
107
+ elsif member.end_with?("ratio")
108
+ [:ratio, nil]
109
+ else
110
+ [:bytes, nil]
111
+ end
112
+ { label:, unit:, max: }
113
+ end
114
+
115
+ # The metric's value in `reading` (the virtual :focus is Focus.value); nil when missing or
116
+ # when the reading raises.
117
+ def metric_value(reading, metric)
118
+ return nil if reading.nil?
119
+
120
+ metric = metric.to_sym
121
+ metric == :focus ? Focus.value(reading) : reading[metric]
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
126
+ # A member of a Data/Struct or Hash (symbol or string key); only Numeric results count.
127
+ def read(object, member)
128
+ value = fetch(object, member)
129
+ value.is_a?(Numeric) ? value : nil
130
+ end
131
+
132
+ def value(signal, reading) = read(metric_value(reading, signal.metric), signal.member)
133
+
134
+ # The `of` signal's value, nil when there is none or it is unknown.
135
+ def total(signal, reading)
136
+ return nil unless signal.of
137
+
138
+ value(parse(signal.of), reading)
139
+ end
140
+
141
+ # value / (total || max) clamped to 0..1; nil when the value or the scale is unknown.
142
+ def fraction(signal, reading)
143
+ current = value(signal, reading) or return nil
144
+ scale = total(signal, reading) || signal.max
145
+ return nil if scale.nil? || !scale.positive?
146
+
147
+ (current.to_f / scale).clamp(0.0, 1.0)
148
+ end
149
+
150
+ # A trend the metric already keeps (`member_trend`, else the member without `_rate`), or nil.
151
+ def trend(signal, reading)
152
+ object = metric_value(reading, signal.metric) or return nil
153
+ member = signal.member.to_s
154
+ [member, member.delete_suffix("_rate")].uniq.each do |base|
155
+ series = fetch(object, :"#{base}_trend")
156
+ return series if series.is_a?(Array)
157
+ end
158
+ nil
159
+ end
160
+
161
+ # `value` as text in `unit`; nil for nil.
162
+ def format(value, unit)
163
+ return nil if value.nil?
164
+
165
+ case unit
166
+ when :integer then value.round.to_s
167
+ when :percent, :bytes, :bytes_per_sec, :number, :ratio then R2UI::Format.call(unit, value)
168
+ when :per_sec then per_sec(value)
169
+ when :signed_bytes_per_sec then signed_bytes_per_sec(value)
170
+ else value.to_s
171
+ end
172
+ end
173
+
174
+ def per_sec(value)
175
+ return "0/s" if value.zero?
176
+
177
+ value.abs >= 10 ? "#{value.round}/s" : Kernel.format("%.1f/s", value)
178
+ end
179
+
180
+ def signed_bytes_per_sec(value)
181
+ return "0B/s" if value.zero?
182
+
183
+ "#{value.positive? ? "+" : "-"}#{R2UI::Format.bytes(value.abs)}/s"
184
+ end
185
+
186
+ def fetch(object, member)
187
+ case object
188
+ when nil then nil
189
+ when Hash
190
+ key = member.to_sym
191
+ object.key?(key) ? object[key] : object[member.to_s]
192
+ when Data then object.to_h[member.to_sym]
193
+ when Struct then object.members.include?(member.to_sym) ? object[member.to_sym] : nil
194
+ else
195
+ name = member.to_sym
196
+ object.respond_to?(name) && object.method(name).arity.zero? ? object.public_send(name) : nil
197
+ end
198
+ rescue StandardError
199
+ nil
200
+ end
201
+
202
+ # The virtual metric :focus: the focused session, else every agent session together.
203
+ # FocusedReading already narrows :session_ledger and :session_memory to the focused session,
204
+ # so this only aggregates what the reading gives (:session_network too).
205
+ module Focus
206
+ LEDGER_SUMS = %i[processes read_rate write_rate].freeze
207
+ MEMORY_SUMS = %i[footprint resident wired peak_footprint growth_rate pagein_rate].freeze
208
+ # focus member => SessionNetwork member summed into it.
209
+ NETWORK_SUMS = { net_in_rate: :in_rate, net_out_rate: :out_rate, bytes_in: :bytes_in, bytes_out: :bytes_out,
210
+ connections: :connections, remote_hosts: :remote_hosts }.freeze
211
+ # focus trend => SessionNetwork trend, used when focused on one session (else History).
212
+ NETWORK_TRENDS = { net_in_trend: :in_trend, net_out_trend: :out_trend }.freeze
213
+
214
+ module_function
215
+
216
+ # A Hash: cpu (percent of the machine), processes, read_rate, write_rate (alive sessions),
217
+ # footprint .. pagein_rate (session memory), net_in_rate .. remote_hosts (session network;
218
+ # remote_hosts sums each session's count, so a host two sessions share counts twice),
219
+ # label, focused, and when focused the session's net_in_trend/net_out_trend. Unknown sums
220
+ # are nil; an empty list sums to 0.
221
+ def value(reading)
222
+ ledger = safe(reading, :session_ledger)
223
+ alive = ledger&.select { |s| s.ended_at.nil? }
224
+ memory = safe(reading, :session_memory)
225
+ network = safe(reading, :session_network)
226
+ focus = reading.respond_to?(:focus) ? reading.focus : nil
227
+
228
+ result = { cpu: cpu(alive, safe(reading, :system)) }
229
+ LEDGER_SUMS.each { |key| result[key] = sum(alive, key) }
230
+ MEMORY_SUMS.each { |key| result[key] = sum(memory, key) }
231
+ NETWORK_SUMS.each { |key, member| result[key] = sum(network, member) }
232
+ trends(network).each { |key, series| result[key] = series } if focus && network&.size == 1
233
+ result[:label] = focus ? label(focus, ledger, memory) : "all agents"
234
+ result[:focused] = !focus.nil?
235
+ result
236
+ end
237
+
238
+ # The one session's own rate trends (Arrays only).
239
+ def trends(network)
240
+ NETWORK_TRENDS.filter_map do |key, member|
241
+ series = Signals.fetch(network.first, member)
242
+ [key, series] if series.is_a?(Array)
243
+ end
244
+ end
245
+
246
+ # Sum of alive sessions' CPU (percent of one core) / ncpu: percent of the machine.
247
+ def cpu(alive, system)
248
+ ncpu = system.respond_to?(:ncpu) ? system.ncpu : nil
249
+ return nil if alive.nil? || !ncpu.is_a?(Numeric) || !ncpu.positive?
250
+ return 0.0 if alive.empty?
251
+
252
+ total = sum(alive, :cpu)
253
+ total && (total.to_f / ncpu)
254
+ end
255
+
256
+ # nil for an unknown list; 0 for an empty one; nil when no item knows the member.
257
+ def sum(items, member)
258
+ return nil if items.nil?
259
+ return 0 if items.empty?
260
+
261
+ known = items.filter_map { |item| Signals.read(item, member) }
262
+ known.empty? ? nil : known.sum
263
+ end
264
+
265
+ def label(focus, ledger, memory)
266
+ session = ledger&.find { |s| s.id == focus }
267
+ row = memory&.find { |m| m.session_id == focus }
268
+ session&.label || row&.label || focus.to_s
269
+ end
270
+
271
+ def safe(reading, name)
272
+ reading[name]
273
+ rescue StandardError
274
+ nil
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end