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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/exe/agentmon +18 -0
- data/lib/agentmon/commands/cli_options.rb +21 -0
- data/lib/agentmon/commands/footprint.rb +81 -0
- data/lib/agentmon/commands/memory.rb +110 -0
- data/lib/agentmon/commands/record.rb +118 -0
- data/lib/agentmon/commands/report.rb +127 -0
- data/lib/agentmon/commands/sessions.rb +98 -0
- data/lib/agentmon/commands/top.rb +87 -0
- data/lib/agentmon/commands/tree.rb +96 -0
- data/lib/agentmon/darwin.rb +235 -0
- data/lib/agentmon/engine.rb +115 -0
- data/lib/agentmon/focus.rb +88 -0
- data/lib/agentmon/metrics/memory.rb +76 -0
- data/lib/agentmon/metrics/network.rb +245 -0
- data/lib/agentmon/metrics/pressure_drivers.rb +57 -0
- data/lib/agentmon/metrics/process_rates.rb +65 -0
- data/lib/agentmon/metrics/process_rows.rb +41 -0
- data/lib/agentmon/metrics/session_ledger.rb +160 -0
- data/lib/agentmon/metrics/session_memory.rb +84 -0
- data/lib/agentmon/metrics/session_names.rb +169 -0
- data/lib/agentmon/metrics/sessions.rb +80 -0
- data/lib/agentmon/metrics/system.rb +114 -0
- data/lib/agentmon/model.rb +263 -0
- data/lib/agentmon/probes/cwd.rb +32 -0
- data/lib/agentmon/probes/memory.rb +139 -0
- data/lib/agentmon/probes/network.rb +326 -0
- data/lib/agentmon/probes/processes.rb +72 -0
- data/lib/agentmon/probes/system.rb +134 -0
- data/lib/agentmon/program.rb +55 -0
- data/lib/agentmon/reading.rb +83 -0
- data/lib/agentmon/recorders/memory.rb +19 -0
- data/lib/agentmon/recorders/sessions.rb +40 -0
- data/lib/agentmon/registry.rb +155 -0
- data/lib/agentmon/sampler.rb +50 -0
- data/lib/agentmon/store.rb +86 -0
- data/lib/agentmon/ui/connections.rb +75 -0
- data/lib/agentmon/ui/interaction.rb +94 -0
- data/lib/agentmon/ui/memory.rb +125 -0
- data/lib/agentmon/ui/process_actions.rb +49 -0
- data/lib/agentmon/ui/process_detail.rb +148 -0
- data/lib/agentmon/ui/process_network.rb +13 -0
- data/lib/agentmon/ui/process_scopes.rb +32 -0
- data/lib/agentmon/ui/process_waits.rb +99 -0
- data/lib/agentmon/ui/processes.rb +42 -0
- data/lib/agentmon/ui/session_focus.rb +126 -0
- data/lib/agentmon/ui/session_memory.rb +78 -0
- data/lib/agentmon/ui/sessions.rb +130 -0
- data/lib/agentmon/ui/theme.rb +49 -0
- data/lib/agentmon/ui.rb +80 -0
- data/lib/agentmon/version.rb +5 -0
- data/lib/agentmon/views/dense.rb +157 -0
- data/lib/agentmon/views/history.rb +62 -0
- data/lib/agentmon/views/signals.rb +279 -0
- data/lib/agentmon/views/visual.rb +172 -0
- data/lib/agentmon/views/widgets.rb +352 -0
- data/lib/agentmon/views.rb +875 -0
- data/lib/agentmon.rb +38 -0
- 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
|
data/lib/agentmon/ui.rb
ADDED
|
@@ -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,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
|