r2ui 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/LICENSE.txt +21 -0
- data/README.md +79 -0
- data/exe/r2ui +27 -0
- data/lib/r2ui/app.rb +156 -0
- data/lib/r2ui/canvas.rb +66 -0
- data/lib/r2ui/dsl/column.rb +24 -0
- data/lib/r2ui/dsl/dashboard.rb +82 -0
- data/lib/r2ui/dsl/resource.rb +108 -0
- data/lib/r2ui/feed.rb +71 -0
- data/lib/r2ui/format.rb +75 -0
- data/lib/r2ui/history.rb +36 -0
- data/lib/r2ui/keys.rb +18 -0
- data/lib/r2ui/panel_state.rb +98 -0
- data/lib/r2ui/query.rb +111 -0
- data/lib/r2ui/registry.rb +30 -0
- data/lib/r2ui/renderer.rb +139 -0
- data/lib/r2ui/search.rb +60 -0
- data/lib/r2ui/terminal.rb +53 -0
- data/lib/r2ui/value.rb +15 -0
- data/lib/r2ui/version.rb +5 -0
- data/lib/r2ui/widgets/box.rb +37 -0
- data/lib/r2ui/widgets/gauge.rb +35 -0
- data/lib/r2ui/widgets/sparkline.rb +42 -0
- data/lib/r2ui/widgets/stat.rb +18 -0
- data/lib/r2ui/widgets/table.rb +134 -0
- data/lib/r2ui.rb +53 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fd8c94c82db6c74efb8c77dba2e80155c61336aa2e2f7e0c49ada508f0ea0ddf
|
|
4
|
+
data.tar.gz: 338646823240fdbecd726268b2ef4f72388a5e7633cbdbd0eb3acd0cc143d96a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 91cc25cf40c7a4beedc55caf86f4dc13703c92c493bbadaccf326a60dea821611024f6a5f9ed8fdb8c6924e25596748c9d0948c6c35cab6291e526321b494ca5
|
|
7
|
+
data.tar.gz: 6c5f8747986ac1b5219ecfe3bc415ccac2ed79d23b107af5ec00616daac862e1e842ef030c0fbc214bba8ff7999351aa513bcda0a88528a77884ca0517bb373b
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Gavin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# R2UI
|
|
2
|
+
|
|
3
|
+
ActiveAdmin-style DSL for terminal dashboards. You declare resources, scopes, groupings, columns and actions; R2UI draws the tables, gauges and sparklines, and handles keys, search, sorting and folding.
|
|
4
|
+
|
|
5
|
+
Pure Ruby, no runtime dependencies.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
R2UI.resource :process do
|
|
9
|
+
source { MyProbe.processes } # any array of hashes, Data/Struct objects or models
|
|
10
|
+
refresh every: 2 # seconds, or an ActiveSupport::Duration
|
|
11
|
+
key :pid, parent: :ppid # identity, plus parent for tree grouping
|
|
12
|
+
|
|
13
|
+
scope :agents, default: true, &:agent
|
|
14
|
+
scope :all
|
|
15
|
+
|
|
16
|
+
group_by :agent, label: "Session" # same value → one line, numbers summed
|
|
17
|
+
group_by :cwd, label: "Directory"
|
|
18
|
+
group_by :parent, tree: true # tree; each node's numbers include its subtree
|
|
19
|
+
|
|
20
|
+
index do
|
|
21
|
+
column :pid, format: :id
|
|
22
|
+
column :name
|
|
23
|
+
column :cwd, format: :short_path
|
|
24
|
+
column :cpu, format: :percent, sparkline: true, sort: :desc
|
|
25
|
+
column :rss, label: "Memory", format: :bytes
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
filter :name, :cwd # what "/" free text matches
|
|
29
|
+
|
|
30
|
+
action :terminate, key: "K", confirm: true do |process|
|
|
31
|
+
Process.kill(:TERM, process.pid)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
R2UI.dashboard do
|
|
36
|
+
row height: 15 do
|
|
37
|
+
panel :memory, span: 1 do
|
|
38
|
+
gauge :used, of: :total
|
|
39
|
+
stat :wired, :compressed, :swap_used
|
|
40
|
+
sparkline :pressure
|
|
41
|
+
end
|
|
42
|
+
panel :sessions, resource: :process, span: 2 do
|
|
43
|
+
table scope: :agents, group_by: :agent
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
row { panel :process } # no block: the resource's full index
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Run it
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
exe/r2ui examples/agents.rb # interactive
|
|
54
|
+
exe/r2ui --snapshot examples/agents.rb # print one frame and exit
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`examples/agents.rb` is a macOS monitor for Claude and Codex runs. It groups processes by agent session (the outermost `claude`/`codex` process, or the desktop app), by working directory, by name or as a tree. It also breaks memory down the way Activity Monitor does: app, wired and compressed memory, compression ratio, swap, swap-out and compression rates, and memory pressure.
|
|
58
|
+
|
|
59
|
+
## Keys
|
|
60
|
+
|
|
61
|
+
| Key | Does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `tab` / `shift-tab` | Focus next / previous panel |
|
|
64
|
+
| `[` `]` | Previous / next scope |
|
|
65
|
+
| `g` | Cycle grouping (off → each `group_by` → off) |
|
|
66
|
+
| `s` / `S` | Next sort column / reverse |
|
|
67
|
+
| `/` | Search: free text, or `cpu>5`, `rss>=500M`, `cwd~starseed`; `esc` clears |
|
|
68
|
+
| `enter` / `space` | Fold or unfold a tree node |
|
|
69
|
+
| `z` | Zoom the focused panel |
|
|
70
|
+
| action keys | Run the action on the selected line (all its rows when grouped) |
|
|
71
|
+
| `q` | Quit |
|
|
72
|
+
|
|
73
|
+
## Formats
|
|
74
|
+
|
|
75
|
+
`:text`, `:id`, `:integer`, `:number`, `:percent`, `:bytes`, `:bytes_per_sec`, `:ratio`, `:short_path`. The format decides display, alignment, how grouped lines aggregate (`:sum`, `:count` or a single shared value) and how search operands are parsed (`500M`, `5%`).
|
|
76
|
+
|
|
77
|
+
## Next: active_tui
|
|
78
|
+
|
|
79
|
+
A Rails engine on top of the same DSL: `ActiveTui.register Order do ... end` reads columns, scopes and associations from the model, `source` defaults to the relation, and actions become model methods. This gem stays Rails-free.
|
data/exe/r2ui
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# r2ui FILE [SCREEN] run the dashboards defined in FILE
|
|
5
|
+
# r2ui --snapshot FILE [SCREEN] print one frame as plain text and exit
|
|
6
|
+
# --width N --height N --ticks N snapshot size, and how many refreshes to take first
|
|
7
|
+
|
|
8
|
+
require "optparse"
|
|
9
|
+
require_relative "../lib/r2ui"
|
|
10
|
+
|
|
11
|
+
options = { snapshot: false, width: 120, height: 40, ticks: 2 }
|
|
12
|
+
parser = OptionParser.new do |o|
|
|
13
|
+
o.banner = "Usage: r2ui [--snapshot] FILE [SCREEN]"
|
|
14
|
+
o.on("--snapshot", "Print one frame and exit") { options[:snapshot] = true }
|
|
15
|
+
o.on("--width N", Integer) { |n| options[:width] = n }
|
|
16
|
+
o.on("--height N", Integer) { |n| options[:height] = n }
|
|
17
|
+
o.on("--ticks N", Integer) { |n| options[:ticks] = n }
|
|
18
|
+
end
|
|
19
|
+
file, screen = parser.parse(ARGV)
|
|
20
|
+
abort(parser.banner) unless file
|
|
21
|
+
|
|
22
|
+
load File.expand_path(file)
|
|
23
|
+
if options[:snapshot]
|
|
24
|
+
puts R2UI.snapshot(screen, width: options[:width], height: options[:height], ticks: options[:ticks])
|
|
25
|
+
else
|
|
26
|
+
R2UI.run(screen)
|
|
27
|
+
end
|
data/lib/r2ui/app.rb
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
# The running program: feeds, panel focus, key handling and the draw loop.
|
|
5
|
+
class App
|
|
6
|
+
FRAME = 0.25
|
|
7
|
+
FLASH_SECONDS = 3
|
|
8
|
+
|
|
9
|
+
def initialize(registry, name = nil)
|
|
10
|
+
@registry = registry
|
|
11
|
+
@dashboard = registry.screen(name)
|
|
12
|
+
resources = @dashboard.panels.map(&:resource).uniq.map { |r| registry.resource(r) }
|
|
13
|
+
@feeds = resources.to_h { |r| [r.name, Feed.new(r)] }
|
|
14
|
+
@states = @dashboard.panels.to_h do |p|
|
|
15
|
+
[p, p.table && PanelState.new(registry.resource(p.resource), p.table)]
|
|
16
|
+
end
|
|
17
|
+
@focus = @dashboard.panels.find(&:table) || @dashboard.panels.first
|
|
18
|
+
@renderer = Renderer.new(registry, @feeds, @states)
|
|
19
|
+
@mode = :normal
|
|
20
|
+
@zoomed = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
terminal = Terminal.new
|
|
25
|
+
@resized = false
|
|
26
|
+
previous_winch = Signal.trap("WINCH") { @resized = true }
|
|
27
|
+
@feeds.each_value(&:start)
|
|
28
|
+
terminal.open do
|
|
29
|
+
loop do
|
|
30
|
+
width, height = terminal.size
|
|
31
|
+
terminal.clear if @resized
|
|
32
|
+
@resized = false
|
|
33
|
+
terminal.draw(frame(width, height))
|
|
34
|
+
break if terminal.read_keys(FRAME).any? { |key| handle(key) == :quit }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
ensure
|
|
38
|
+
@feeds.each_value(&:stop)
|
|
39
|
+
Signal.trap("WINCH", previous_winch || "DEFAULT")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def snapshot(width:, height:, ticks: 1)
|
|
43
|
+
ticks.times do |i|
|
|
44
|
+
sleep(@feeds.values.map { |f| f.resource.interval }.min) if i.positive?
|
|
45
|
+
@feeds.each_value(&:refresh!)
|
|
46
|
+
end
|
|
47
|
+
frame(width, height).plain_lines.join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Exposed for tests: feed keys without a terminal.
|
|
51
|
+
def press(*keys) = keys.each { |k| handle(k) }
|
|
52
|
+
|
|
53
|
+
def frame(width, height)
|
|
54
|
+
@renderer.render(@dashboard, width:, height:, focus: @focus, zoomed: @zoomed, prompt:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def state = @states[@focus]
|
|
60
|
+
|
|
61
|
+
def lines = @renderer.lines.fetch(@focus, [])
|
|
62
|
+
|
|
63
|
+
def resource = @registry.resource(@focus.resource)
|
|
64
|
+
|
|
65
|
+
def prompt
|
|
66
|
+
case @mode
|
|
67
|
+
when :search then "/#{state.search}▏"
|
|
68
|
+
when Array then "#{@mode[1].label} #{@mode[2].size} #{@mode[2].size == 1 ? "row" : "rows"}? y/n"
|
|
69
|
+
else @flash if @flash && Time.now - @flash_at < FLASH_SECONDS
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def handle(key)
|
|
74
|
+
return :quit if key == :interrupt
|
|
75
|
+
|
|
76
|
+
case @mode
|
|
77
|
+
when :search then search_key(key)
|
|
78
|
+
when Array then confirm_key(key)
|
|
79
|
+
else normal_key(key)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normal_key(key)
|
|
84
|
+
case key
|
|
85
|
+
when "q" then return :quit
|
|
86
|
+
when :tab then cycle_focus(1)
|
|
87
|
+
when :back_tab then cycle_focus(-1)
|
|
88
|
+
when "z" then @zoomed = !@zoomed
|
|
89
|
+
end
|
|
90
|
+
table_key(key) if state
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def table_key(key)
|
|
95
|
+
case key
|
|
96
|
+
when :up, "k" then state.move(-1, lines.size)
|
|
97
|
+
when :down, "j" then state.move(1, lines.size)
|
|
98
|
+
when :page_up then state.move(-10, lines.size)
|
|
99
|
+
when :page_down then state.move(10, lines.size)
|
|
100
|
+
when :home then state.move(-lines.size, lines.size)
|
|
101
|
+
when :end then state.move(lines.size, lines.size)
|
|
102
|
+
when "[" then state.cycle_scope(-1)
|
|
103
|
+
when "]" then state.cycle_scope(1)
|
|
104
|
+
when "g" then state.cycle_grouping
|
|
105
|
+
when "s" then state.cycle_sort
|
|
106
|
+
when "S" then state.reverse_sort
|
|
107
|
+
when "/" then @mode = :search
|
|
108
|
+
when :enter, " " then state.toggle(lines[state.selected])
|
|
109
|
+
else
|
|
110
|
+
action = resource.actions.find { |a| a.key == key }
|
|
111
|
+
start_action(action) if action
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def search_key(key)
|
|
116
|
+
case key
|
|
117
|
+
when :enter then @mode = :normal
|
|
118
|
+
when :escape
|
|
119
|
+
state.search = ""
|
|
120
|
+
@mode = :normal
|
|
121
|
+
when :backspace then state.search = state.search[0...-1]
|
|
122
|
+
when String then state.search += key if key.match?(/[[:print:]]/)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def start_action(action)
|
|
127
|
+
rows = lines[state.selected]&.rows
|
|
128
|
+
return if rows.nil? || rows.empty?
|
|
129
|
+
|
|
130
|
+
action.confirm ? @mode = [:confirm, action, rows] : run_action(action, rows)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def confirm_key(key)
|
|
134
|
+
_, action, rows = @mode
|
|
135
|
+
@mode = :normal
|
|
136
|
+
run_action(action, rows) if key == "y"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def run_action(action, rows)
|
|
140
|
+
rows.each { |row| action.handler.call(row) }
|
|
141
|
+
flash("#{action.label}: #{rows.size} done")
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
flash("#{action.label} failed: #{e.message}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def flash(text)
|
|
147
|
+
@flash = text
|
|
148
|
+
@flash_at = Time.now
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def cycle_focus(step)
|
|
152
|
+
panels = @dashboard.panels
|
|
153
|
+
@focus = panels[(panels.index(@focus) + step) % panels.size]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/r2ui/canvas.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
Rect = Data.define(:x, :y, :width, :height) do
|
|
5
|
+
def inner = Rect.new(x: x + 1, y: y + 1, width: [width - 2, 0].max, height: [height - 2, 0].max)
|
|
6
|
+
def bottom = y + height
|
|
7
|
+
def empty? = width <= 0 || height <= 0
|
|
8
|
+
|
|
9
|
+
# Split off the top `lines` rows: [top, rest].
|
|
10
|
+
def take(lines)
|
|
11
|
+
lines = lines.clamp(0, height)
|
|
12
|
+
[with(height: lines), with(y: y + lines, height: height - lines)]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A grid of styled characters. Widgets draw into it; the terminal (or a snapshot) prints it.
|
|
17
|
+
class Canvas
|
|
18
|
+
STYLES = {
|
|
19
|
+
plain: "0", bold: "1", dim: "2", reverse: "7",
|
|
20
|
+
title: "1;36", header: "1;37", border: "90", focus: "36", selected: "7;36",
|
|
21
|
+
ok: "32", warn: "33", alert: "31", accent: "35", muted: "90"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :width, :height
|
|
25
|
+
|
|
26
|
+
def initialize(width, height)
|
|
27
|
+
@width = width
|
|
28
|
+
@height = height
|
|
29
|
+
@chars = Array.new(height) { Array.new(width, " ") }
|
|
30
|
+
@styles = Array.new(height) { Array.new(width, :plain) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def write(x, y, text, style = :plain, max: nil)
|
|
34
|
+
return if y.negative? || y >= height
|
|
35
|
+
|
|
36
|
+
limit = [max || width, width - x].min
|
|
37
|
+
text.to_s.each_char.first([limit, 0].max).each_with_index do |ch, i|
|
|
38
|
+
next if (x + i).negative?
|
|
39
|
+
|
|
40
|
+
@chars[y][x + i] = ch
|
|
41
|
+
@styles[y][x + i] = style
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fill(rect, char = " ", style = :plain)
|
|
46
|
+
rect.height.times { |dy| write(rect.x, rect.y + dy, char * rect.width, style) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def plain_lines = @chars.map { |row| row.join.rstrip }
|
|
50
|
+
|
|
51
|
+
# ANSI for each line, so the terminal can redraw only lines that changed.
|
|
52
|
+
def ansi_lines
|
|
53
|
+
@chars.each_index.map do |y|
|
|
54
|
+
out = +""
|
|
55
|
+
current = nil
|
|
56
|
+
@chars[y].each_with_index do |ch, x|
|
|
57
|
+
style = @styles[y][x]
|
|
58
|
+
out << "\e[0;#{STYLES.fetch(style)}m" if style != current
|
|
59
|
+
current = style
|
|
60
|
+
out << ch
|
|
61
|
+
end
|
|
62
|
+
out << "\e[0m"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module DSL
|
|
5
|
+
# One attribute of a resource: how to read it, show it and roll it up.
|
|
6
|
+
Column = Data.define(:key, :label, :format, :sparkline, :aggregate, :width, :align, :sort, :reader) do
|
|
7
|
+
def self.build(key, label: nil, format: :text, sparkline: false, aggregate: nil, width: nil, align: nil,
|
|
8
|
+
sort: nil, &reader)
|
|
9
|
+
new(
|
|
10
|
+
key:, format:, sparkline:, width:, sort:, reader:,
|
|
11
|
+
label: label || key.to_s.tr("_", " ").capitalize,
|
|
12
|
+
aggregate: aggregate || Format.default_aggregate(format),
|
|
13
|
+
align: align || Format.default_align(format)
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def read(row) = reader ? reader.call(row) : Value.fetch(row, key)
|
|
18
|
+
|
|
19
|
+
def numeric? = Format.numeric?(format)
|
|
20
|
+
|
|
21
|
+
def render(value) = Format.call(format, value)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module DSL
|
|
5
|
+
Gauge = Data.define(:attr, :of, :label)
|
|
6
|
+
Stat = Data.define(:attrs)
|
|
7
|
+
Sparkline = Data.define(:attr, :label, :height)
|
|
8
|
+
Table = Data.define(:scope, :group_by, :sort, :limit)
|
|
9
|
+
|
|
10
|
+
Panel = Data.define(:name, :resource, :span, :title, :items) do
|
|
11
|
+
def table = items.find { |i| i.is_a?(Table) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Row = Data.define(:height, :panels)
|
|
15
|
+
|
|
16
|
+
# A screen made of rows of panels. Each panel shows one resource.
|
|
17
|
+
class Dashboard
|
|
18
|
+
attr_reader :name, :title, :rows
|
|
19
|
+
|
|
20
|
+
def self.build(name, &block)
|
|
21
|
+
dashboard = new(name)
|
|
22
|
+
Builder.new(dashboard).instance_eval(&block) if block
|
|
23
|
+
dashboard
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A one-panel dashboard showing a resource's full index.
|
|
27
|
+
def self.for_resource(resource)
|
|
28
|
+
build(resource.name) { row { panel resource.name } }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(name)
|
|
32
|
+
@name = name.to_sym
|
|
33
|
+
@title = name.to_s.tr("_", " ").capitalize
|
|
34
|
+
@rows = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def panels = rows.flat_map(&:panels)
|
|
38
|
+
|
|
39
|
+
class Builder
|
|
40
|
+
def initialize(dashboard) = @d = dashboard
|
|
41
|
+
|
|
42
|
+
def title(text) = @d.instance_variable_set(:@title, text)
|
|
43
|
+
|
|
44
|
+
# `height:` in terminal lines; rows without one share the remaining space.
|
|
45
|
+
def row(height: nil, &block)
|
|
46
|
+
builder = RowBuilder.new
|
|
47
|
+
builder.instance_eval(&block)
|
|
48
|
+
@d.rows << Row.new(height:, panels: builder.panels)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class RowBuilder
|
|
53
|
+
attr_reader :panels
|
|
54
|
+
|
|
55
|
+
def initialize = @panels = []
|
|
56
|
+
|
|
57
|
+
# `span:` is a relative width; panels in a row split it by their spans.
|
|
58
|
+
def panel(name, resource: name, span: 1, title: nil, &block)
|
|
59
|
+
items = PanelBuilder.new.tap { |b| b.instance_eval(&block) if block }.items
|
|
60
|
+
items = [Table.new(scope: nil, group_by: nil, sort: nil, limit: nil)] if items.empty?
|
|
61
|
+
@panels << Panel.new(name:, resource:, span:, title:, items:)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class PanelBuilder
|
|
66
|
+
attr_reader :items
|
|
67
|
+
|
|
68
|
+
def initialize = @items = []
|
|
69
|
+
|
|
70
|
+
def gauge(attr, of:, label: nil) = @items << Gauge.new(attr:, of:, label:)
|
|
71
|
+
|
|
72
|
+
def stat(*attrs) = @items << Stat.new(attrs:)
|
|
73
|
+
|
|
74
|
+
def sparkline(attr, label: nil, height: 3) = @items << Sparkline.new(attr:, label:, height:)
|
|
75
|
+
|
|
76
|
+
def table(scope: nil, group_by: nil, sort: nil, limit: nil)
|
|
77
|
+
@items << Table.new(scope:, group_by:, sort:, limit:)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module DSL
|
|
5
|
+
Scope = Data.define(:name, :label, :default, :filter) do
|
|
6
|
+
def call(rows) = filter ? rows.select(&filter) : rows
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# group_by :name (rows with the same value) or group_by :parent, tree: true (subtree totals).
|
|
10
|
+
Grouping = Data.define(:name, :label, :tree, :by) do
|
|
11
|
+
def tree? = tree
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Action = Data.define(:name, :label, :key, :confirm, :handler)
|
|
15
|
+
|
|
16
|
+
# A resource definition. Built by Resource::Builder from the block given to R2UI.resource.
|
|
17
|
+
class Resource
|
|
18
|
+
attr_reader :name, :title, :source, :interval, :key, :parent_key, :scopes, :groupings, :columns,
|
|
19
|
+
:searchable, :actions
|
|
20
|
+
|
|
21
|
+
def self.build(name, &block)
|
|
22
|
+
resource = new(name)
|
|
23
|
+
Builder.new(resource).instance_eval(&block) if block
|
|
24
|
+
resource.validate!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(name)
|
|
28
|
+
@name = name.to_sym
|
|
29
|
+
@title = name.to_s.tr("_", " ").capitalize
|
|
30
|
+
@interval = 1.0
|
|
31
|
+
@scopes = []
|
|
32
|
+
@groupings = []
|
|
33
|
+
@columns = []
|
|
34
|
+
@searchable = []
|
|
35
|
+
@actions = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def column(key) = columns.find { |c| c.key == key }
|
|
39
|
+
|
|
40
|
+
def default_scope = scopes.find(&:default) || scopes.first
|
|
41
|
+
|
|
42
|
+
def default_sort
|
|
43
|
+
col = columns.find(&:sort)
|
|
44
|
+
col ? [col.key, col.sort] : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fetch
|
|
48
|
+
rows = source.call
|
|
49
|
+
rows.respond_to?(:to_ary) ? rows.to_ary : [rows]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def identify(row) = key ? Value.fetch(row, key) : row.object_id
|
|
53
|
+
|
|
54
|
+
def validate!
|
|
55
|
+
raise Error, "resource #{name} needs a `source { ... }` block" unless source
|
|
56
|
+
if groupings.any?(&:tree?) && !(key && parent_key)
|
|
57
|
+
raise Error, "resource #{name}: tree grouping needs `key :id_attr, parent: :parent_attr`"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The DSL. Each method records a declaration on the resource.
|
|
64
|
+
class Builder
|
|
65
|
+
def initialize(resource) = @r = resource
|
|
66
|
+
|
|
67
|
+
def title(text) = set(:title, text)
|
|
68
|
+
|
|
69
|
+
def source(&block) = set(:source, block)
|
|
70
|
+
|
|
71
|
+
# `every:` takes seconds, or anything with #to_f (an ActiveSupport::Duration).
|
|
72
|
+
def refresh(every:) = set(:interval, every.to_f)
|
|
73
|
+
|
|
74
|
+
# Which attribute identifies a row, and optionally which points at its parent row.
|
|
75
|
+
def key(attr, parent: nil)
|
|
76
|
+
set(:key, attr)
|
|
77
|
+
set(:parent_key, parent)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def scope(name, label: nil, default: false, &filter)
|
|
81
|
+
@r.scopes << Scope.new(name:, label: label || humanize(name), default:, filter:)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def group_by(name, label: nil, tree: false, by: name)
|
|
85
|
+
@r.groupings << Grouping.new(name:, label: label || humanize(name), tree:, by:)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def index(&) = instance_eval(&)
|
|
89
|
+
|
|
90
|
+
def column(key, **, &) = @r.columns << Column.build(key, **, &)
|
|
91
|
+
alias attribute column
|
|
92
|
+
|
|
93
|
+
# Attributes that free-text search ("/") matches against.
|
|
94
|
+
def filter(*keys) = @r.searchable.concat(keys)
|
|
95
|
+
|
|
96
|
+
def action(name, key:, label: nil, confirm: false, &handler)
|
|
97
|
+
@r.actions << Action.new(name:, label: label || humanize(name), key:, confirm:, handler:)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def set(ivar, value) = @r.instance_variable_set(:"@#{ivar}", value)
|
|
103
|
+
|
|
104
|
+
def humanize(sym) = sym.to_s.tr("_", " ").capitalize
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/r2ui/feed.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
# Fetches one resource on its refresh interval in a background thread and records history.
|
|
5
|
+
class Feed
|
|
6
|
+
SINGLE = :record
|
|
7
|
+
|
|
8
|
+
attr_reader :resource, :history
|
|
9
|
+
|
|
10
|
+
def initialize(resource)
|
|
11
|
+
@resource = resource
|
|
12
|
+
@history = History.new
|
|
13
|
+
@lock = Mutex.new
|
|
14
|
+
@rows = []
|
|
15
|
+
@error = nil
|
|
16
|
+
@version = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rows = @lock.synchronize { @rows }
|
|
20
|
+
def error = @lock.synchronize { @error }
|
|
21
|
+
def version = @lock.synchronize { @version }
|
|
22
|
+
|
|
23
|
+
# History id for a row: its key, or :record when the resource returns a single record.
|
|
24
|
+
def series_id(row) = @resource.key ? @resource.identify(row) : SINGLE
|
|
25
|
+
|
|
26
|
+
def start(&on_update)
|
|
27
|
+
@thread = Thread.new do
|
|
28
|
+
loop do
|
|
29
|
+
refresh!
|
|
30
|
+
on_update&.call
|
|
31
|
+
sleep @resource.interval
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stop
|
|
38
|
+
@thread&.kill
|
|
39
|
+
@thread&.join(1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def refresh!
|
|
43
|
+
rows = @resource.fetch
|
|
44
|
+
@lock.synchronize do
|
|
45
|
+
record(rows)
|
|
46
|
+
@rows = rows
|
|
47
|
+
@error = nil
|
|
48
|
+
@version += 1
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
@error = "#{e.class}: #{e.message}"
|
|
53
|
+
@version += 1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def record(rows)
|
|
60
|
+
return unless @resource.key || rows.size == 1
|
|
61
|
+
|
|
62
|
+
numeric = @resource.columns.select(&:numeric?)
|
|
63
|
+
ids = rows.map do |row|
|
|
64
|
+
id = series_id(row)
|
|
65
|
+
numeric.each { |c| @history.record(id, c.key, c.read(row) || 0) }
|
|
66
|
+
id
|
|
67
|
+
end
|
|
68
|
+
@history.prune(ids)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|