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,875 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "views/signals"
|
|
4
|
+
require_relative "views/history"
|
|
5
|
+
require_relative "views/widgets"
|
|
6
|
+
|
|
7
|
+
# Performance views: a small DSL for dashboards of the machine, a process or a session, on top of
|
|
8
|
+
# r2ui's rows and panels (docs/views.md is the contract). A ten-line view already looks finished:
|
|
9
|
+
#
|
|
10
|
+
# Agentmon.view :mine, title: "agentmon" do
|
|
11
|
+
# row height: 8 do
|
|
12
|
+
# panel :cpu, title: "CPU" do
|
|
13
|
+
# meter "system.cpu" # CPU ▕████▎ ▏ 42.0%
|
|
14
|
+
# trend "system.cpu", height: 4 # braille area, the last four minutes
|
|
15
|
+
# end
|
|
16
|
+
# panel :memory, title: "Memory" do
|
|
17
|
+
# meter "memory.used" # used ▕██████ ▏ 23G / 32G 71%
|
|
18
|
+
# stat "memory.app", "memory.wired", "memory.compressed", "memory.swap_used"
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# row { top :process, by: :cpu, columns: %i[name session cpu footprint] }
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# agentmon --layout mine
|
|
25
|
+
#
|
|
26
|
+
# Words (all take signals, "metric.member", with defaults from views/signals.rb):
|
|
27
|
+
# view-level row, focused { rows shown instead while a session is focused } (Views::Drill)
|
|
28
|
+
# row-level panel, use :name (a registered agentmon panel), top, band, detail
|
|
29
|
+
# in a panel meter, trend, spark, stat, top, band, detail
|
|
30
|
+
#
|
|
31
|
+
# How it maps onto r2ui: `Agentmon.view` records a View (this file's builders; r2ui's own
|
|
32
|
+
# PanelBuilder already has `stat`, so the view words live on agentmon's builders, not r2ui's).
|
|
33
|
+
# `UI.install(engine:, view:)` turns it into the :agentmon r2ui dashboard: `top` becomes
|
|
34
|
+
# `table(columns:, sort:, limit:, motion: true)`, every other word a panel item drawn by the
|
|
35
|
+
# :agentmon_views extension below through Widgets (pure strings). Panels with only view words get
|
|
36
|
+
# the hidden :view_signals resource, whose feed keeps the engine sampling in the background (and
|
|
37
|
+
# so marks new frames due). Values are read from `engine.current(max_age: ∞)`, never sampled on
|
|
38
|
+
# the drawing thread.
|
|
39
|
+
module Agentmon
|
|
40
|
+
module Views
|
|
41
|
+
# --- the recorded view ---
|
|
42
|
+
|
|
43
|
+
# `rows`: the home rows; `focused`: the rows shown instead while a session is focused
|
|
44
|
+
# (`focused do ... end`; empty when the view has no drill-down).
|
|
45
|
+
View = Data.define(:name, :title, :rows, :focused) do
|
|
46
|
+
def initialize(name:, title:, rows:, focused: [].freeze) = super
|
|
47
|
+
def drill? = !focused.empty?
|
|
48
|
+
def all_rows = rows + focused
|
|
49
|
+
end
|
|
50
|
+
RowDef = Data.define(:height, :panels)
|
|
51
|
+
PanelDef = Data.define(:name, :title, :span, :resource, :items, :options)
|
|
52
|
+
UseDef = Data.define(:name, :span, :title)
|
|
53
|
+
|
|
54
|
+
# `part`: a Signal stacked into the bar from the left (a share of the meter's whole), or nil.
|
|
55
|
+
Meter = Data.define(:signal, :heat, :part) do
|
|
56
|
+
def initialize(signal:, heat:, part: nil) = super
|
|
57
|
+
end
|
|
58
|
+
Trend = Data.define(:signals, :height)
|
|
59
|
+
Spark = Data.define(:signal)
|
|
60
|
+
Stat = Data.define(:signals, :columns)
|
|
61
|
+
# `widths`: { column => cells } fixes text columns and cuts their values at word boundaries.
|
|
62
|
+
# `spark`: true puts a braille sparkline on the sort column, false on none (narrow panels), an
|
|
63
|
+
# Array of column keys on those columns.
|
|
64
|
+
Top = Data.define(:resource, :by, :limit, :columns, :group_by, :widths, :spark) do
|
|
65
|
+
# The columns that get a sparkline.
|
|
66
|
+
def spark_columns
|
|
67
|
+
case spark
|
|
68
|
+
when true then [by].compact.map(&:to_sym)
|
|
69
|
+
when false, nil then []
|
|
70
|
+
else Array(spark).map(&:to_sym)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
Band = Data.define(:resource, :show)
|
|
75
|
+
Detail = Data.define(:resource, :columns)
|
|
76
|
+
|
|
77
|
+
SIGNALS = :view_signals
|
|
78
|
+
FPS = 10
|
|
79
|
+
PULSE_SECONDS = 0.5
|
|
80
|
+
TWEEN_SECONDS = 0.35
|
|
81
|
+
|
|
82
|
+
# Prepended to a view app's Motion: `hold` (r2ui's table gutter) no longer keeps frames due.
|
|
83
|
+
module NoHold
|
|
84
|
+
def hold(_seconds) = nil
|
|
85
|
+
end
|
|
86
|
+
MIN_TILE = 18 # room for a "claude 12345" title uncut
|
|
87
|
+
|
|
88
|
+
class << self
|
|
89
|
+
# The view UI.install is building (nil for the default dashboard): resource decorations
|
|
90
|
+
# (heat styles, braille sort column, priorities) apply only then.
|
|
91
|
+
attr_accessor :installing
|
|
92
|
+
|
|
93
|
+
# --no-motion / AGENTMON_MOTION=0 turn animation off (R2UI::Motion#enabled).
|
|
94
|
+
attr_writer :motion
|
|
95
|
+
|
|
96
|
+
def motion? = @motion.nil? ? ENV["AGENTMON_MOTION"] != "0" : @motion
|
|
97
|
+
|
|
98
|
+
def build(name, title:, &block)
|
|
99
|
+
builder = ViewBuilder.new(title)
|
|
100
|
+
builder.instance_eval(&block)
|
|
101
|
+
View.new(name: name.to_sym, title: builder.title, rows: builder.rows.freeze,
|
|
102
|
+
focused: builder.focused_rows.freeze)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def signal(name, **) = Signals.parse(name.to_s, **)
|
|
106
|
+
|
|
107
|
+
# Every Top item of `view` for `resource` (home and focused rows).
|
|
108
|
+
def tops(view, resource)
|
|
109
|
+
view.all_rows.flat_map(&:panels).grep(PanelDef).flat_map(&:items).grep(Top).select { |t| t.resource == resource }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class ViewBuilder
|
|
114
|
+
attr_reader :rows, :focused_rows
|
|
115
|
+
|
|
116
|
+
def initialize(title)
|
|
117
|
+
@title = title
|
|
118
|
+
@rows = []
|
|
119
|
+
@focused_rows = []
|
|
120
|
+
@in_focused = false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def title(text = nil)
|
|
124
|
+
@title = text if text
|
|
125
|
+
@title
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Inside `focused`, rows go to the drill-down instead of the home rows.
|
|
129
|
+
def row(height: nil, &block)
|
|
130
|
+
builder = RowBuilder.new
|
|
131
|
+
builder.instance_eval(&block)
|
|
132
|
+
(@in_focused ? @focused_rows : @rows) << RowDef.new(height:, panels: builder.panels.freeze)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The rows shown instead of the home rows while a session is focused (engine.focus): Enter on
|
|
136
|
+
# the Sessions table (or the band) drills in, Escape comes back. Once per view, not nested.
|
|
137
|
+
def focused(&block)
|
|
138
|
+
raise ArgumentError, "focused: needs a block of rows" unless block
|
|
139
|
+
raise ArgumentError, "focused: not inside another focused" if @in_focused
|
|
140
|
+
raise ArgumentError, "focused: only once per view" if @focused_rows.any?
|
|
141
|
+
|
|
142
|
+
@in_focused = true
|
|
143
|
+
instance_eval(&block)
|
|
144
|
+
ensure
|
|
145
|
+
@in_focused = false
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class RowBuilder
|
|
150
|
+
attr_reader :panels
|
|
151
|
+
|
|
152
|
+
def initialize = @panels = []
|
|
153
|
+
|
|
154
|
+
# A panel of view words. `resource:` is set by a `top` inside, else the hidden signals feed.
|
|
155
|
+
def panel(name, title: nil, span: 1, resource: nil, **options, &block)
|
|
156
|
+
builder = PanelBuilder.new
|
|
157
|
+
builder.instance_eval(&block) if block
|
|
158
|
+
items = builder.items.freeze
|
|
159
|
+
resource ||= items.grep(Top).first&.resource || SIGNALS
|
|
160
|
+
@panels << PanelDef.new(name: name.to_sym, title:, span:, resource:, items:, options:)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# An existing registered agentmon panel (:memory, :session, :process, :detail, ...).
|
|
164
|
+
def use(name, span: nil, title: nil) = @panels << UseDef.new(name: name.to_sym, span:, title:)
|
|
165
|
+
|
|
166
|
+
# Row-level shortcuts: one panel named after what it shows.
|
|
167
|
+
def top(resource, span: 1, title: nil, **)
|
|
168
|
+
panel(resource, title:, span:, resource:) { top(resource, **) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def band(resource = :session, span: 1, title: "Sessions", **)
|
|
172
|
+
panel(:band, title:, span:) { band(resource, **) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def detail(resource = :process, span: 1, title: "Detail", **)
|
|
176
|
+
panel(:detail, title:, span:) { detail(resource, **) }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class PanelBuilder
|
|
181
|
+
attr_reader :items
|
|
182
|
+
|
|
183
|
+
def initialize = @items = []
|
|
184
|
+
|
|
185
|
+
# `part:` stacks another signal into the bar: `meter "memory.used", part: "focus.footprint"`
|
|
186
|
+
# draws the part from the left in the accent, the rest of used dim, free memory empty, and
|
|
187
|
+
# the text "part share · used/total" (the share is of the total). Needs a total (`of:`).
|
|
188
|
+
def meter(sig, of: nil, max: nil, label: nil, unit: nil, heat: true, part: nil)
|
|
189
|
+
signal = Views.signal(sig, of:, max:, label:, unit:)
|
|
190
|
+
raise ArgumentError, "meter #{sig} part: needs a total (of:)" if part && signal.of.nil?
|
|
191
|
+
|
|
192
|
+
@items << Meter.new(signal:, heat:, part: part && Views.signal(part))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Several signals stack, sharing `height` chart lines (nil: the rest of the panel; a Float
|
|
196
|
+
# below 1: that share of the lines left, labels included, e.g. 0.6 above a `top`).
|
|
197
|
+
def trend(*sigs, height: 3, label: nil, max: nil, unit: nil)
|
|
198
|
+
@items << Trend.new(signals: sigs.map { |s| Views.signal(s, label:, max:, unit:) }.freeze, height:)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def spark(sig, label: nil, max: nil, unit: nil) = @items << Spark.new(signal: Views.signal(sig, label:, max:, unit:))
|
|
202
|
+
|
|
203
|
+
def stat(*sigs, columns: 2) = @items << Stat.new(signals: sigs.map { |s| Views.signal(s) }.freeze, columns:)
|
|
204
|
+
|
|
205
|
+
# `spark:` true (the `by:` column), false (none) or column keys (`%i[cpu footprint]`).
|
|
206
|
+
def top(resource, by: nil, limit: nil, columns: nil, group_by: nil, widths: {}, spark: true)
|
|
207
|
+
spark = Array(spark).map(&:to_sym).freeze unless [true, false, nil].include?(spark)
|
|
208
|
+
@items << Top.new(resource: resource.to_sym, by:, limit:, columns: columns&.map(&:to_sym), group_by:,
|
|
209
|
+
widths: widths.transform_keys(&:to_sym).freeze, spark:)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def band(resource = :session, show: %i[cpu footprint]) = @items << Band.new(resource:, show:)
|
|
213
|
+
|
|
214
|
+
def detail(resource = :process, columns: 1) = @items << Detail.new(resource:, columns:)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- per-app drawing state ---
|
|
218
|
+
|
|
219
|
+
# The engine, signal history, last shown texts (for pulses) and a cache of drawn strings, one
|
|
220
|
+
# per dashboard built from a view (declared on it, so drawers find it).
|
|
221
|
+
class Runtime
|
|
222
|
+
CACHE_LIMIT = 4000
|
|
223
|
+
|
|
224
|
+
attr_reader :engine, :view, :history
|
|
225
|
+
|
|
226
|
+
def initialize(engine:, view:)
|
|
227
|
+
@engine = engine
|
|
228
|
+
@view = view
|
|
229
|
+
@history = History.new
|
|
230
|
+
@shown = {}
|
|
231
|
+
@cache = {}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The latest reading (focused when a session is), never sampling here: feeds keep it fresh.
|
|
235
|
+
def reading = @engine.current(max_age: Float::INFINITY)
|
|
236
|
+
|
|
237
|
+
def machine = @engine.current(max_age: Float::INFINITY, focused: false)
|
|
238
|
+
|
|
239
|
+
# The signal's recent values: the metric's own trend, else ours (appended once per reading).
|
|
240
|
+
def series(signal, reading)
|
|
241
|
+
Signals.trend(signal, reading) ||
|
|
242
|
+
@history.record(history_key(signal), reading.at, Signals.value(signal, reading))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def history_key(signal) = signal.metric == :focus ? "#{signal.name}@#{@engine.focus}" : signal.name
|
|
246
|
+
|
|
247
|
+
# 0..1, quantized: 1.0 on the frame `text` changed for `key`, fading over a second.
|
|
248
|
+
def pulse(motion, key, text)
|
|
249
|
+
before = @shown[key]
|
|
250
|
+
@shown[key] = text
|
|
251
|
+
level = motion.pulse(key, !before.nil? && before != text, duration: PULSE_SECONDS)
|
|
252
|
+
(level * 12).round / 12.0
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# A drawn string for `key`, built once per distinct key.
|
|
256
|
+
def cached(key)
|
|
257
|
+
@cache.clear if @cache.size > CACHE_LIMIT
|
|
258
|
+
@cache.fetch(key) { @cache[key] = yield }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# --- the drill-down (`focused`): which r2ui rows are on the dashboard ---
|
|
262
|
+
|
|
263
|
+
# Splits the r2ui dashboard's rows (built as home rows, then focused rows) into the two sets.
|
|
264
|
+
def attach(dashboard)
|
|
265
|
+
rows = dashboard.rows.dup
|
|
266
|
+
@home_rows = rows.first(@view.rows.size).freeze
|
|
267
|
+
@focused_rows = rows.drop(@view.rows.size).freeze
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def drill? = @view.drill?
|
|
271
|
+
|
|
272
|
+
def mode = @engine.focus ? :focused : :home
|
|
273
|
+
|
|
274
|
+
# Puts the set for the current mode on the dashboard (only that set: its layout, panels, tab
|
|
275
|
+
# order and drawing are exactly those of a view with only these rows).
|
|
276
|
+
def show(dashboard)
|
|
277
|
+
want = mode == :focused ? @focused_rows : @home_rows
|
|
278
|
+
rows = dashboard.rows
|
|
279
|
+
return if rows.size == want.size && rows.each_with_index.all? { |r, i| r.equal?(want[i]) }
|
|
280
|
+
|
|
281
|
+
rows.replace(want)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# --- what each word shows ---
|
|
286
|
+
|
|
287
|
+
module Draw
|
|
288
|
+
module_function
|
|
289
|
+
|
|
290
|
+
def text(signal, value) = Signals.format(value, signal.unit)
|
|
291
|
+
|
|
292
|
+
# "42.0%", or "23G / 32G 71%" with a total.
|
|
293
|
+
def meter_text(signal, reading)
|
|
294
|
+
value = Signals.value(signal, reading)
|
|
295
|
+
return nil if value.nil?
|
|
296
|
+
|
|
297
|
+
total = Signals.total(signal, reading)
|
|
298
|
+
return text(signal, value) unless total&.positive?
|
|
299
|
+
|
|
300
|
+
"#{text(signal, value)} / #{text(signal, total)} #{(value * 100.0 / total).round}%"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# A stacked meter's fractions of the total and text, e.g. [0.02, 0.56, "387M 1% · 9.0G/16G"]:
|
|
304
|
+
# the part (clamped to the whole's value), the whole's value, then "part share · value/total"
|
|
305
|
+
# (share of the total, "<1%" below one percent). Any of them unknown: [nil, nil, nil].
|
|
306
|
+
def stacked(item, reading)
|
|
307
|
+
sig = item.signal
|
|
308
|
+
used = Signals.value(sig, reading)
|
|
309
|
+
total = Signals.total(sig, reading)
|
|
310
|
+
part = Signals.value(item.part, reading)
|
|
311
|
+
return [nil, nil, nil] if used.nil? || part.nil? || !total&.positive?
|
|
312
|
+
|
|
313
|
+
share = part * 100.0 / total
|
|
314
|
+
pct = share.positive? && share < 0.5 ? "<1%" : "#{share.round}%"
|
|
315
|
+
text = "#{text(item.part, part)} #{pct} · #{text(sig, used)}/#{text(sig, total)}"
|
|
316
|
+
[([part, used].min.to_f / total).clamp(0.0, 1.0), (used.to_f / total).clamp(0.0, 1.0), text]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def item_meter_text(item, reading) = item.part ? stacked(item, reading)[2] : meter_text(item.signal, reading)
|
|
320
|
+
|
|
321
|
+
def max_width(texts) = texts.map { |t| Widgets.visible_width(t || Widgets::UNKNOWN) }.max || 0
|
|
322
|
+
|
|
323
|
+
# Labels and texts of a panel's one-line words share widths, so bars and charts line up.
|
|
324
|
+
def label_width(panel)
|
|
325
|
+
panel.items.filter_map { |i| (i.respond_to?(:signal) && i.signal.label) || nil }.map(&:length).max
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def meter(ctx, rt, item)
|
|
329
|
+
return stacked_meter(ctx, rt, item) if item.part
|
|
330
|
+
|
|
331
|
+
reading = rt.reading
|
|
332
|
+
sig = item.signal
|
|
333
|
+
fraction = Signals.fraction(sig, reading)
|
|
334
|
+
text = meter_text(sig, reading)
|
|
335
|
+
shown = fraction && ctx.motion.tween([:agentmon_meter, ctx.panel.name, sig.name], fraction,
|
|
336
|
+
duration: TWEEN_SECONDS)
|
|
337
|
+
pulse = rt.pulse(ctx.motion, [:agentmon_pulse, ctx.panel.name, sig.name], text)
|
|
338
|
+
text_w = max_width(ctx.panel.items.grep(Meter).map { |m| item_meter_text(m, reading) })
|
|
339
|
+
label_w = label_width(ctx.panel)
|
|
340
|
+
width = ctx.width
|
|
341
|
+
steps = shown && (shown * width * 8).round
|
|
342
|
+
rt.cached([:meter, item, width, steps, text, pulse, text_w, label_w, fraction&.round(2)]) do
|
|
343
|
+
bar = item.heat && fraction ? R2UI::Widgets::Glyphs.heat(shown) : R2UI::Widgets::Glyphs.fg(Widgets::ACCENT)
|
|
344
|
+
Widgets.meter(label: sig.label, text:, fraction: shown, width:, label_width: label_w, text_width: text_w,
|
|
345
|
+
bar_sgr: bar, text_sgr: value_sgr(sig, fraction, pulse, text))
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# `meter sig, part:`: both fractions eased (keys per panel and signal), the text pulsing.
|
|
350
|
+
def stacked_meter(ctx, rt, item)
|
|
351
|
+
reading = rt.reading
|
|
352
|
+
sig = item.signal
|
|
353
|
+
part, used, text = stacked(item, reading)
|
|
354
|
+
panel = ctx.panel.name
|
|
355
|
+
shown = used && ctx.motion.tween([:agentmon_meter, panel, sig.name], used, duration: TWEEN_SECONDS)
|
|
356
|
+
shown_part = part && ctx.motion.tween([:agentmon_meter_part, panel, sig.name, item.part.name], part,
|
|
357
|
+
duration: TWEEN_SECONDS)
|
|
358
|
+
pulse = rt.pulse(ctx.motion, [:agentmon_pulse, panel, sig.name, item.part.name], text)
|
|
359
|
+
text_w = max_width(ctx.panel.items.grep(Meter).map { |m| item_meter_text(m, reading) })
|
|
360
|
+
label_w = label_width(ctx.panel)
|
|
361
|
+
width = ctx.width
|
|
362
|
+
steps = [shown, shown_part].map { |f| f && (f * width * 8).round }
|
|
363
|
+
rt.cached([:stacked_meter, item, width, steps, text, pulse, text_w, label_w]) do
|
|
364
|
+
Widgets.stacked_meter(label: sig.label, text:, part: shown_part, fraction: shown, width:,
|
|
365
|
+
label_width: label_w, text_width: text_w,
|
|
366
|
+
text_sgr: Widgets.value_sgr(unit: item.part.unit, pulse:, known: !text.nil?))
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def value_sgr(sig, fraction, pulse, text)
|
|
371
|
+
Widgets.value_sgr(unit: sig.unit, fraction: sig.unit == :percent ? fraction : nil, pulse:, known: !text.nil?)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def chart_sgr(sig, fraction)
|
|
375
|
+
sig.max && fraction ? R2UI::Widgets::Glyphs.heat(fraction) : R2UI::Widgets::Glyphs.fg(Widgets::ACCENT)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def trend(ctx, rt, item)
|
|
379
|
+
reading = rt.reading
|
|
380
|
+
sigs = item.signals
|
|
381
|
+
room = case item.height
|
|
382
|
+
when nil then ctx.height - sigs.size # the rest of the panel
|
|
383
|
+
when Float then (ctx.height * item.height).floor - sigs.size # a share of what is left
|
|
384
|
+
else item.height
|
|
385
|
+
end
|
|
386
|
+
room = [room, sigs.size].max
|
|
387
|
+
share, extra = room.divmod(sigs.size)
|
|
388
|
+
sigs.each_with_index.flat_map do |sig, i|
|
|
389
|
+
values = rt.series(sig, reading)
|
|
390
|
+
value = Signals.value(sig, reading)
|
|
391
|
+
fraction = Signals.fraction(sig, reading)
|
|
392
|
+
text = text(sig, value)
|
|
393
|
+
pulse = rt.pulse(ctx.motion, [:agentmon_pulse, ctx.panel.name, sig.name], text)
|
|
394
|
+
height = share + (i < extra ? 1 : 0)
|
|
395
|
+
# The header pulses frame by frame; the braille area changes once per sample, so it is
|
|
396
|
+
# cached on the series itself (a pulse frame redraws only the header).
|
|
397
|
+
header = rt.cached([:trend_head, sig.label, ctx.width, text, pulse, fraction&.round(2)]) do
|
|
398
|
+
Widgets.sides(sig.label.to_s, R2UI::Widgets::Glyphs.fg(Widgets::MUTED), text || Widgets::UNKNOWN,
|
|
399
|
+
value_sgr(sig, fraction, pulse, text), ctx.width)
|
|
400
|
+
end
|
|
401
|
+
area = rt.cached([:trend_area, ctx.width, height, sig.max, values.hash, fraction&.round(2)]) do
|
|
402
|
+
Widgets.trend(label: "", text: "", values:, width: ctx.width, height:, max: sig.max,
|
|
403
|
+
chart_sgr: chart_sgr(sig, fraction)).drop(1)
|
|
404
|
+
end
|
|
405
|
+
[header, *area]
|
|
406
|
+
end.join("\n")
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def spark(ctx, rt, item)
|
|
410
|
+
reading = rt.reading
|
|
411
|
+
sig = item.signal
|
|
412
|
+
values = rt.series(sig, reading)
|
|
413
|
+
fraction = Signals.fraction(sig, reading)
|
|
414
|
+
text = text(sig, Signals.value(sig, reading))
|
|
415
|
+
pulse = rt.pulse(ctx.motion, [:agentmon_pulse, ctx.panel.name, sig.name], text)
|
|
416
|
+
label_w = label_width(ctx.panel)
|
|
417
|
+
text_w = max_width(ctx.panel.items.grep(Spark).map { |s| text(s.signal, Signals.value(s.signal, reading)) })
|
|
418
|
+
rt.cached([:spark, item, ctx.width, values.hash, text, pulse, label_w, text_w]) do
|
|
419
|
+
Widgets.spark(label: sig.label, values:, text:, width: ctx.width, max: sig.max, label_width: label_w,
|
|
420
|
+
text_width: text_w, chart_sgr: chart_sgr(sig, fraction),
|
|
421
|
+
text_sgr: value_sgr(sig, fraction, pulse, text))
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def stat(ctx, rt, item)
|
|
426
|
+
reading = rt.reading
|
|
427
|
+
columns = ctx.width < 40 ? 1 : item.columns
|
|
428
|
+
pairs = item.signals.map do |sig|
|
|
429
|
+
text = text(sig, Signals.value(sig, reading))
|
|
430
|
+
pulse = rt.pulse(ctx.motion, [:agentmon_pulse, ctx.panel.name, sig.name], text)
|
|
431
|
+
[sig.label, text, value_sgr(sig, Signals.fraction(sig, reading), pulse, text)]
|
|
432
|
+
end
|
|
433
|
+
rt.cached([:stat, item, ctx.width, columns, pairs]) do
|
|
434
|
+
Widgets.stat(pairs:, width: ctx.width, columns:).join("\n")
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# The Detail panel's pairs for the selected process, reflowed into `columns` columns that
|
|
439
|
+
# fill the panel top to bottom. The command line (wrapped by ProcessDetail) is joined back
|
|
440
|
+
# and cut at a word boundary instead.
|
|
441
|
+
def detail(ctx, rt, item)
|
|
442
|
+
columns = [item.columns, 1].max
|
|
443
|
+
gap = 3
|
|
444
|
+
col_w = (ctx.width - (gap * (columns - 1))) / columns
|
|
445
|
+
text = UI::ProcessDetail.render(ctx.app, rt.engine, ctx.width)
|
|
446
|
+
lines = join_wrapped(text.split("\n"))
|
|
447
|
+
rt.cached([:detail, ctx.width, ctx.height, lines]) do
|
|
448
|
+
# The heading (name and pid) gets its own full-width line; the pairs flow in columns.
|
|
449
|
+
head, *pairs = lines
|
|
450
|
+
head = R2UI::Widgets::Glyphs.paint(Widgets.cut(head.to_s, ctx.width), "1")
|
|
451
|
+
rows = [(pairs.size + columns - 1) / columns, ctx.height - 1].min
|
|
452
|
+
rows = 0 if rows.negative?
|
|
453
|
+
cells = pairs.first(rows * columns).map { |line| fit_pair(line, col_w) }
|
|
454
|
+
body = Array.new(rows) do |r|
|
|
455
|
+
Array.new(columns) { |c| Widgets.pad(cells[(c * rows) + r] || "", col_w) }.join(" " * gap)
|
|
456
|
+
end
|
|
457
|
+
[head, *body].join("\n")
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
LABEL = UI::ProcessDetail::LABEL_WIDTH
|
|
462
|
+
|
|
463
|
+
def join_wrapped(lines)
|
|
464
|
+
lines.each_with_object([]) do |line, out|
|
|
465
|
+
if line.start_with?(" " * LABEL) && out.last&.start_with?("Command")
|
|
466
|
+
out[-1] = out.last + line[LABEL..]
|
|
467
|
+
else
|
|
468
|
+
out << line
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def fit_pair(line, width)
|
|
474
|
+
return Widgets.cut(line, width) if line.length <= width || line.length <= LABEL
|
|
475
|
+
|
|
476
|
+
label = line[0, LABEL]
|
|
477
|
+
value = line[LABEL..]
|
|
478
|
+
label + Widgets.cut(value, [width - LABEL, 0].max)
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# --- the band of session tiles ---
|
|
483
|
+
|
|
484
|
+
module BandDraw
|
|
485
|
+
Tile = Data.define(:id, :title, :cpu, :values, :series_key)
|
|
486
|
+
|
|
487
|
+
module_function
|
|
488
|
+
|
|
489
|
+
# The machine first (selected when nothing is focused), then each live session, oldest first.
|
|
490
|
+
def tiles(rt, item)
|
|
491
|
+
reading = rt.machine
|
|
492
|
+
system = reading[:system]
|
|
493
|
+
memory = reading[:memory]
|
|
494
|
+
machine = Tile.new(id: nil, title: "machine", cpu: system&.cpu,
|
|
495
|
+
values: [["used", memory&.used && R2UI::Format.bytes(memory.used), :bytes]],
|
|
496
|
+
series_key: :machine)
|
|
497
|
+
sessions = (reading[:session_ledger] || []).select(&:alive?).sort_by { |s| [s.started_at || 0, s.id] }
|
|
498
|
+
[machine] + sessions.map do |s|
|
|
499
|
+
values = (item.show - [:cpu]).map do |member|
|
|
500
|
+
unit = Signals.infer(member)[:unit]
|
|
501
|
+
[Signals.infer(member)[:label], Signals.format(Signals.read(s, member), unit), unit]
|
|
502
|
+
end
|
|
503
|
+
Tile.new(id: s.id, title: s.label, cpu: s.cpu, values:, series_key: s.id)
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def draw(ctx, rt, item)
|
|
508
|
+
all = tiles(rt, item)
|
|
509
|
+
width = ctx.width
|
|
510
|
+
fit = [width / MIN_TILE, 1].max
|
|
511
|
+
shown = all.size <= fit ? all : all.first(fit - 1)
|
|
512
|
+
more = all.size - shown.size
|
|
513
|
+
slots = shown.size + (more.positive? ? 1 : 0)
|
|
514
|
+
tile_w = width / slots
|
|
515
|
+
reading = rt.machine
|
|
516
|
+
cursor = ctx.store(:agentmon_band)[:cursor]
|
|
517
|
+
focus = rt.engine.focus
|
|
518
|
+
columns = shown.each_with_index.map do |tile, i|
|
|
519
|
+
w = i == slots - 1 && more.zero? ? width - (tile_w * (slots - 1)) : tile_w
|
|
520
|
+
tile_lines(ctx, rt, tile, w, ctx.height, focused: tile.id == focus, cursor: cursor == i, at: reading.at)
|
|
521
|
+
end
|
|
522
|
+
if more.positive?
|
|
523
|
+
w = width - (tile_w * shown.size)
|
|
524
|
+
columns << Widgets.tile(title: "+#{more}", body: ["#{more} more", "→ to pick"], width: w,
|
|
525
|
+
height: ctx.height, border_sgr: R2UI::Widgets::Glyphs.fg(Widgets::DIM))
|
|
526
|
+
end
|
|
527
|
+
Array.new(ctx.height) { |row| columns.map { |c| c[row] || "" }.join }.join("\n")
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def tile_lines(ctx, rt, tile, width, height, focused:, cursor:, at:)
|
|
531
|
+
glow = ctx.motion.tween([:agentmon_band, tile.series_key], focused ? 1.0 : 0.0, duration: 0.3)
|
|
532
|
+
values = rt.history.record("band.cpu.#{tile.series_key}", at, tile.cpu)
|
|
533
|
+
cpu_text = tile.cpu && Kernel.format("%.0f%%", tile.cpu)
|
|
534
|
+
pulse = rt.pulse(ctx.motion, [:agentmon_band_pulse, tile.series_key], cpu_text)
|
|
535
|
+
fraction = tile.cpu && (tile.cpu / 100.0).clamp(0.0, 1.0)
|
|
536
|
+
shown = fraction && ctx.motion.tween([:agentmon_band_cpu, tile.series_key], fraction, duration: TWEEN_SECONDS)
|
|
537
|
+
key = [:tile, tile, width, height, (glow * 16).round, cursor, values.hash,
|
|
538
|
+
shown && (shown * width * 8).round, pulse]
|
|
539
|
+
rt.cached(key) do
|
|
540
|
+
inner = width - 2
|
|
541
|
+
border = if cursor then "1;#{R2UI::Widgets::Glyphs.fg("#E0E0E0")}"
|
|
542
|
+
else R2UI::Widgets::Glyphs.fg(R2UI::Motion.mix_hex(Widgets::DIM, Widgets::ACCENT, glow))
|
|
543
|
+
end
|
|
544
|
+
title_sgr = glow > 0.5 ? "1;#{R2UI::Widgets::Glyphs.fg(Widgets::ACCENT)}" : R2UI::Widgets::Glyphs.fg("#BCBCBC")
|
|
545
|
+
text_sgr = Widgets.value_sgr(unit: :percent, fraction:, pulse:, known: !cpu_text.nil?)
|
|
546
|
+
body = [Widgets.meter(label: "cpu", text: cpu_text, fraction: shown, width: inner, text_width: 4,
|
|
547
|
+
text_sgr:)]
|
|
548
|
+
pairs = tile.values.map { |label, text, unit| [label, text, Widgets.value_sgr(unit:, known: !text.nil?)] }
|
|
549
|
+
body.concat(Widgets.stat(pairs:, width: inner, columns: 1))
|
|
550
|
+
body << Widgets.spark(label: "", values:, text: "", width: inner, max: 100,
|
|
551
|
+
chart_sgr: R2UI::Widgets::Glyphs.heat(fraction || 0))
|
|
552
|
+
Widgets.tile(title: tile.title, body: body.first([height - 2, 0].max), width:, height:,
|
|
553
|
+
border_sgr: border, title_sgr:)
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Key handling: ←/→ (h/l) move a cursor over the tiles, Enter focuses the tile's session
|
|
558
|
+
# (the machine tile clears the focus), Escape drops the cursor (and the focus, through
|
|
559
|
+
# ui/session_focus.rb).
|
|
560
|
+
def key(ctx, rt, item, key)
|
|
561
|
+
store = ctx.store(:agentmon_band)
|
|
562
|
+
count = tiles(rt, item).size
|
|
563
|
+
case key
|
|
564
|
+
when :left, "h", :right, "l"
|
|
565
|
+
step = %i[left].include?(key) || key == "h" ? -1 : 1
|
|
566
|
+
start = store[:cursor] || tiles(rt, item).index { |t| t.id == rt.engine.focus } || 0
|
|
567
|
+
store[:cursor] = (start + step) % count
|
|
568
|
+
true
|
|
569
|
+
when :enter
|
|
570
|
+
return false unless store[:cursor]
|
|
571
|
+
|
|
572
|
+
tile = tiles(rt, item)[store.delete(:cursor).clamp(0, count - 1)]
|
|
573
|
+
UI::SessionFocus.focus!(ctx.app, rt.engine, tile.id)
|
|
574
|
+
true
|
|
575
|
+
when :escape
|
|
576
|
+
had = store.delete(:cursor)
|
|
577
|
+
!had.nil? && rt.engine.focus.nil?
|
|
578
|
+
else false
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# --- the drill-down: home rows, or the focused rows while a session is focused ---
|
|
584
|
+
|
|
585
|
+
# r2ui has no way to hide panels or switch a dashboard's rows, so the view's r2ui dashboard is
|
|
586
|
+
# built with both sets (App.new then makes feeds and table states for every panel) and the
|
|
587
|
+
# Runtime swaps `dashboard.rows` to the set for engine.focus before every key and every frame.
|
|
588
|
+
# Only the visible set's panels are on the dashboard: r2ui lays them out as if alone (fixed
|
|
589
|
+
# heights, shared rest, the last row taking what is left), tab cycles only them, hidden panels
|
|
590
|
+
# draw nothing and lookups by name (:session, :process, :detail) find the visible one.
|
|
591
|
+
module Drill
|
|
592
|
+
STORE = :agentmon_drill
|
|
593
|
+
HOME = "sessions · ⏎ open a session"
|
|
594
|
+
BACK = "esc back to sessions"
|
|
595
|
+
|
|
596
|
+
module_function
|
|
597
|
+
|
|
598
|
+
# Shows the set for the current mode and moves key focus when the mode changed: to the
|
|
599
|
+
# first table panel of the focused set when drilling in, back to the home panel that had it
|
|
600
|
+
# when coming out (the first home table panel when there is none, e.g. at start).
|
|
601
|
+
def sync(app, rt)
|
|
602
|
+
return unless rt&.drill?
|
|
603
|
+
|
|
604
|
+
dashboard = app.dashboard
|
|
605
|
+
rt.show(dashboard)
|
|
606
|
+
store = app.store(STORE)
|
|
607
|
+
mode = rt.mode
|
|
608
|
+
before = store[:mode]
|
|
609
|
+
panels = dashboard.panels
|
|
610
|
+
return if before == mode && panels.include?(app.focus)
|
|
611
|
+
|
|
612
|
+
store[:mode] = mode
|
|
613
|
+
store[:home_focus] = app.focus if before == :home
|
|
614
|
+
target = mode == :home ? store.delete(:home_focus) : nil
|
|
615
|
+
target = nil unless panels.include?(target)
|
|
616
|
+
target ||= panels.find(&:table) || panels.first
|
|
617
|
+
app.focus = target if target
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# The status bar on a view with a drill-down: where you are and how to go back.
|
|
621
|
+
def status(rt)
|
|
622
|
+
return nil unless rt&.drill?
|
|
623
|
+
|
|
624
|
+
engine = rt.engine
|
|
625
|
+
id = engine.focus or return HOME
|
|
626
|
+
session = engine.focused_session
|
|
627
|
+
label = session&.label || id.to_s
|
|
628
|
+
status = session.respond_to?(:status) && session.status # Claude Code's "busy" / "idle"
|
|
629
|
+
"▸ #{label}#{" · #{status}" if status} · #{BACK}"
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# --- resource decoration (only while a view is installed) ---
|
|
634
|
+
|
|
635
|
+
module Decorate
|
|
636
|
+
PRIORITIES = {
|
|
637
|
+
process: { name: 9, cpu: 9, footprint: 8, session: 6, pid: 5, run_wait: 4, read_rate: 4, write_rate: 4,
|
|
638
|
+
net_out_rate: 4, net_in_rate: 3, pagein_rate: 3, resident: 2, cwd: 1 },
|
|
639
|
+
connection: { process: 9, remote_text: 8, out_rate: 7, in_rate: 7, state: 6, bytes_out: 5, bytes_in: 5,
|
|
640
|
+
protocol: 4 },
|
|
641
|
+
session: { label: 9, cpu: 9, footprint: 8, processes: 6, age: 5, net_out_rate: 4, net_in_rate: 4,
|
|
642
|
+
peak_footprint: 3, remote_hosts: 3, connections: 2, read_rate: 2, write_rate: 2, cpu_seconds: 1,
|
|
643
|
+
bytes_written: 1, bytes_in: 1, bytes_out: 1 }
|
|
644
|
+
}.freeze
|
|
645
|
+
# The anomaly mark (docs/views.md, "Anomaly mark"): a session row whose `net_flags` is not
|
|
646
|
+
# empty shows FLAG before its label, in FLAG_COLOR. The metric decides; this only marks.
|
|
647
|
+
FLAG = "⚑ "
|
|
648
|
+
FLAG_COLOR = "#E5484D"
|
|
649
|
+
|
|
650
|
+
# Text columns get a fixed width in a view, so their values are cut at a word boundary here
|
|
651
|
+
# (r2ui's table cuts a flexible column mid-word). `top widths:` overrides these.
|
|
652
|
+
WIDTHS = {
|
|
653
|
+
process: { name: 18, session: 16, cwd: 20 },
|
|
654
|
+
session: { label: 18 }
|
|
655
|
+
}.freeze
|
|
656
|
+
FROM_LEFT = %i[cwd].freeze
|
|
657
|
+
|
|
658
|
+
module_function
|
|
659
|
+
|
|
660
|
+
# Heat on percent cells (a full core is red), the accent on bytes, a braille sparkline on
|
|
661
|
+
# each `spark:` column (the sort column by default), priorities for dropping, fixed word-cut
|
|
662
|
+
# widths (WIDTHS, then `top widths:`) and the anomaly mark on session labels. Applies to
|
|
663
|
+
# every panel of the resource on a view dashboard, `use` ones too.
|
|
664
|
+
def call(resource, name)
|
|
665
|
+
view = Views.installing or return
|
|
666
|
+
tops = Views.tops(view, name)
|
|
667
|
+
sparks = tops.flat_map(&:spark_columns).uniq
|
|
668
|
+
sparks = [:cpu] if tops.empty?
|
|
669
|
+
widths = tops.map(&:widths).reduce(WIDTHS.fetch(name, {}), :merge)
|
|
670
|
+
resource.columns.map! { |col| column(col, name, sparks, widths) }
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def flagged?(row)
|
|
674
|
+
flags = Signals.fetch(row, :net_flags)
|
|
675
|
+
flags.respond_to?(:empty?) && !flags.empty?
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# The label with the mark when `row` is flagged, cut at a word boundary to `width` cells
|
|
679
|
+
# (nil: uncut).
|
|
680
|
+
def flag_label(row, value, width)
|
|
681
|
+
return (width ? Widgets.cut(value, width) : value) unless value.is_a?(String) && flagged?(row)
|
|
682
|
+
|
|
683
|
+
FLAG + (width ? Widgets.cut(value, width - Widgets.visible_width(FLAG)) : value)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def column(col, name, sorts, widths)
|
|
687
|
+
opts = { priority: PRIORITIES.dig(name, col.key) || 0 }
|
|
688
|
+
case col.format
|
|
689
|
+
when :percent
|
|
690
|
+
opts[:style] = ->(value, _line) { value.is_a?(Numeric) ? R2UI::Widgets::Glyphs.heat(value / 100.0) : nil }
|
|
691
|
+
when :bytes, :bytes_per_sec
|
|
692
|
+
opts[:style] = ->(value, _line) { value.is_a?(Numeric) ? R2UI::Widgets::Glyphs.fg(Widgets::ACCENT) : nil }
|
|
693
|
+
end
|
|
694
|
+
if sorts.include?(col.key) && col.numeric?
|
|
695
|
+
opts.merge!(sparkline: :braille, spark_width: 5, spark_max: col.format == :percent ? 100 : nil)
|
|
696
|
+
if col.format == :percent
|
|
697
|
+
opts[:spark_style] = ->(values, _line) { R2UI::Widgets::Glyphs.heat((values.last || 0) / 100.0) }
|
|
698
|
+
end
|
|
699
|
+
elsif col.sparkline
|
|
700
|
+
opts[:sparkline] = false
|
|
701
|
+
end
|
|
702
|
+
reader = col.reader
|
|
703
|
+
key = col.key
|
|
704
|
+
w = widths[key]
|
|
705
|
+
if name == :session && key == :label
|
|
706
|
+
opts[:width] = w if w
|
|
707
|
+
opts[:reader] = lambda do |row|
|
|
708
|
+
flag_label(row, reader ? reader.call(row) : R2UI::Value.fetch(row, key), w)
|
|
709
|
+
end
|
|
710
|
+
red = R2UI::Widgets::Glyphs.fg(FLAG_COLOR)
|
|
711
|
+
opts[:style] = ->(value, _line) { value.is_a?(String) && value.start_with?(FLAG) ? red : nil }
|
|
712
|
+
elsif w
|
|
713
|
+
opts[:width] = w
|
|
714
|
+
opts[:reader] = lambda do |row|
|
|
715
|
+
value = reader ? reader.call(row) : R2UI::Value.fetch(row, key)
|
|
716
|
+
value.is_a?(String) ? Widgets.cut(value, w, from: FROM_LEFT.include?(key) ? :left : :right) : value
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
col.with(**opts)
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Reached through the resource builder: r2ui has no way to amend a column another file
|
|
724
|
+
# declared (`column` with the same key appends a second one), so this edits the list.
|
|
725
|
+
%i[process session connection].each do |name|
|
|
726
|
+
Agentmon.extend_resource(name) do |_engine|
|
|
727
|
+
Decorate.call(instance_variable_get(:@r), name)
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# --- r2ui wiring ---
|
|
732
|
+
|
|
733
|
+
module_function
|
|
734
|
+
|
|
735
|
+
# The r2ui dashboard (named like the default one, so every agentmon dashboard extension
|
|
736
|
+
# applies) built from `view`'s rows: the home rows, then the focused ones (Drill shows one set).
|
|
737
|
+
def dashboard(view, engine, registry)
|
|
738
|
+
extras = registry.dashboard_blocks
|
|
739
|
+
runtime = Runtime.new(engine:, view:)
|
|
740
|
+
built = R2UI::DSL::Dashboard.build(UI::DASHBOARD) do
|
|
741
|
+
title view.title
|
|
742
|
+
extras.each { |b| instance_exec(engine, &b) }
|
|
743
|
+
agentmon_view(runtime)
|
|
744
|
+
view.all_rows.each do |spec|
|
|
745
|
+
row(height: spec.height) do
|
|
746
|
+
spec.panels.each { |p| Views.place(self, p, engine, registry, runtime) }
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
runtime.attach(built)
|
|
751
|
+
built
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def place(row, spec, engine, registry, runtime)
|
|
755
|
+
if spec.is_a?(UseDef)
|
|
756
|
+
p = registry[:panel, spec.name] or raise Error, "use #{spec.name}: no such agentmon panel"
|
|
757
|
+
items = p.block && proc { instance_exec(engine, &p.block) }
|
|
758
|
+
return row.panel(p.name, resource: p.resource, span: spec.span || p.span, title: spec.title || p.title,
|
|
759
|
+
**p.options, &items)
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
options = spec.options
|
|
763
|
+
if spec.items.any?(Band)
|
|
764
|
+
options = options.merge(border_style: lambda {
|
|
765
|
+
engine.focus ? R2UI::Widgets::Glyphs.fg(Widgets::ACCENT) : nil
|
|
766
|
+
})
|
|
767
|
+
end
|
|
768
|
+
row.panel(spec.name, resource: spec.resource, span: spec.span, title: spec.title, **options) do
|
|
769
|
+
spec.items.each do |item|
|
|
770
|
+
if item.is_a?(Top)
|
|
771
|
+
sort = item.by && [item.by, :desc]
|
|
772
|
+
table(sort:, limit: item.limit, columns: item.columns, group_by: item.group_by, motion: true)
|
|
773
|
+
else
|
|
774
|
+
agentmon_view_item(item)
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# The hidden resource behind panels with only view words: its feed samples the engine on
|
|
781
|
+
# the feed thread every interval (so drawers never do) and marks frames due.
|
|
782
|
+
# When the view also shows a table (or a `use` panel), that resource's feed already samples
|
|
783
|
+
# every interval and marks frames due, so this one refreshes once an hour: every feed refresh
|
|
784
|
+
# is a frame, and a second feed at another phase would double the idle frames.
|
|
785
|
+
def signals_resource(engine, view = Views.installing)
|
|
786
|
+
tables = view && view.all_rows.flat_map(&:panels).any? { |p| p.is_a?(UseDef) || p.resource != SIGNALS }
|
|
787
|
+
every = tables ? 3600 : engine.interval
|
|
788
|
+
R2UI::DSL::Resource.build(SIGNALS) do
|
|
789
|
+
title "Signals"
|
|
790
|
+
source do
|
|
791
|
+
engine.current
|
|
792
|
+
[]
|
|
793
|
+
end
|
|
794
|
+
refresh every:
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
R2UI.extension :agentmon_views do
|
|
800
|
+
dsl :dashboard do
|
|
801
|
+
def agentmon_view(runtime) = declare(:agentmon_view, runtime)
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
dsl :panel do
|
|
805
|
+
def agentmon_view_item(value) = item(value)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
helpers do
|
|
809
|
+
# The Runtime of the view dashboard drawing; nil on other dashboards.
|
|
810
|
+
def view_runtime = dashboard.declared(:agentmon_view).first
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
setup do
|
|
814
|
+
app.motion.enabled = Views.motion?
|
|
815
|
+
# Cost (docs/views.md, "Motion"): r2ui's table gutter holds the motion active for 1.5-2 s
|
|
816
|
+
# after every reorder, and a process list reorders on nearly every sample, so frames would
|
|
817
|
+
# never stop. On a view dashboard the gutter marks show and fade with the frames that tweens
|
|
818
|
+
# and pulses already draw, and go at the next sample, instead of keeping 20 fps running.
|
|
819
|
+
app.motion.singleton_class.prepend(Views::NoHold) if view_runtime
|
|
820
|
+
Views::Drill.sync(app, view_runtime)
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# The drill-down follows engine.focus: before any handler sees a message (so tab, Enter and
|
|
824
|
+
# the name lookups see only the visible panels), after handlers changed the focus, and before
|
|
825
|
+
# every frame (`styles` is the only hook App#frame runs before laying out; a focus set
|
|
826
|
+
# directly, or a session that ended, shows on the next frame).
|
|
827
|
+
observe { |_message| Views::Drill.sync(app, view_runtime) }
|
|
828
|
+
after_update { Views::Drill.sync(app, view_runtime) }
|
|
829
|
+
styles do
|
|
830
|
+
rt = view_runtime
|
|
831
|
+
if rt&.drill?
|
|
832
|
+
UI::SessionFocus.clear_if_gone(app, rt.engine)
|
|
833
|
+
Views::Drill.sync(app, rt)
|
|
834
|
+
end
|
|
835
|
+
nil
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# Where you are and how to go back, on views with a drill-down. r2ui shows the first status
|
|
839
|
+
# hook that returns text, in load order, and has no priority for them (extension.rb:87), so
|
|
840
|
+
# this registers through the hook method with one above session_focus's "focus: ..." (which
|
|
841
|
+
# loads first). Problems still win: with engine errors this returns nil and agentmon_errors
|
|
842
|
+
# shows them.
|
|
843
|
+
hook(:status, nil, proc {
|
|
844
|
+
rt = view_runtime
|
|
845
|
+
rt && Agentmon.engine_errors.empty? ? Views::Drill.status(rt) : nil
|
|
846
|
+
}, priority: 10)
|
|
847
|
+
|
|
848
|
+
# Animation needs fewer frames than r2ui's default 20 fps to look smooth at these durations.
|
|
849
|
+
program_options do
|
|
850
|
+
view_runtime && Views.motion? ? { fps: Views::FPS } : nil
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
panel_item(Views::Meter) { |item| Views::Draw.meter(self, view_runtime, item) }
|
|
854
|
+
panel_item(Views::Trend) { |item| Views::Draw.trend(self, view_runtime, item) }
|
|
855
|
+
panel_item(Views::Spark) { |item| Views::Draw.spark(self, view_runtime, item) }
|
|
856
|
+
panel_item(Views::Stat) { |item| Views::Draw.stat(self, view_runtime, item) }
|
|
857
|
+
panel_item(Views::Band) { |item| Views::BandDraw.draw(self, view_runtime, item) }
|
|
858
|
+
panel_item(Views::Detail) { |item| Views::Draw.detail(self, view_runtime, item) }
|
|
859
|
+
|
|
860
|
+
on(->(m) { m.is_a?(Bubbletea::KeyMessage) }, priority: 60) do |message|
|
|
861
|
+
rt = view_runtime or pass
|
|
862
|
+
band = dashboard.panels.flat_map(&:items).grep(Views::Band).first or pass
|
|
863
|
+
Views::BandDraw.key(self, rt, band, R2UI::Keys.name(message)) or pass
|
|
864
|
+
nil
|
|
865
|
+
end
|
|
866
|
+
# No "←→ pick" hint: r2ui's status bar drops every hint when they don't all fit beside the
|
|
867
|
+
# status text (renderer.rb draw_status), and at 100 columns one more would hide them all.
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# The layouts: one file each, written only in the words above.
|
|
872
|
+
%w[dense focus visual].each do |name|
|
|
873
|
+
path = File.join(__dir__, "views", "#{name}.rb")
|
|
874
|
+
require path if File.exist?(path)
|
|
875
|
+
end
|