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
data/lib/r2ui/format.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
# Column formats: how a value is shown, aligned, aggregated and parsed back from a search.
|
|
5
|
+
module Format
|
|
6
|
+
NUMERIC = %i[bytes bytes_per_sec percent number integer ratio].freeze
|
|
7
|
+
SUMMED = %i[bytes bytes_per_sec percent number integer].freeze
|
|
8
|
+
UNITS = { "" => 1, "K" => 1024, "M" => 1024**2, "G" => 1024**3, "T" => 1024**4 }.freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def numeric?(format) = NUMERIC.include?(format)
|
|
13
|
+
|
|
14
|
+
# What a grouped line shows for this column: :sum, :count or :distinct.
|
|
15
|
+
def default_aggregate(format)
|
|
16
|
+
return :sum if SUMMED.include?(format)
|
|
17
|
+
return :count if format == :id
|
|
18
|
+
|
|
19
|
+
:distinct
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def default_align(format) = numeric?(format) || format == :id ? :right : :left
|
|
23
|
+
|
|
24
|
+
# Paths keep their tail when they don't fit.
|
|
25
|
+
def truncate_from(format) = format == :short_path ? :left : :right
|
|
26
|
+
|
|
27
|
+
def default_width(format)
|
|
28
|
+
case format
|
|
29
|
+
when :bytes, :bytes_per_sec then 8
|
|
30
|
+
when :percent then 7
|
|
31
|
+
when :ratio then 6
|
|
32
|
+
when :id, :integer, :number then 8
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(format, value)
|
|
37
|
+
return "" if value.nil?
|
|
38
|
+
|
|
39
|
+
case format
|
|
40
|
+
when :bytes then bytes(value)
|
|
41
|
+
when :bytes_per_sec then "#{bytes(value)}/s"
|
|
42
|
+
when :percent then format("%.1f%%", value)
|
|
43
|
+
when :ratio then format("%.1fx", value)
|
|
44
|
+
when :number then value.is_a?(Float) ? format("%.1f", value) : value.to_s
|
|
45
|
+
when :short_path then short_path(value)
|
|
46
|
+
else value.to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def bytes(value)
|
|
51
|
+
value = value.to_f
|
|
52
|
+
unit = UNITS.keys.reverse.find { |u| value >= UNITS[u] } || ""
|
|
53
|
+
scaled = value / UNITS[unit]
|
|
54
|
+
unit.empty? ? "#{value.round}B" : format(scaled < 10 ? "%.1f%s" : "%.0f%s", scaled, unit)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def short_path(value)
|
|
58
|
+
home = Dir.home
|
|
59
|
+
value.to_s.start_with?(home) ? "~#{value.to_s.delete_prefix(home)}" : value.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Parse a search operand ("500M", "1.5G", "5") into the column's units.
|
|
63
|
+
def parse(format, text)
|
|
64
|
+
case format
|
|
65
|
+
when :bytes, :bytes_per_sec
|
|
66
|
+
m = text.strip.upcase.match(/\A([\d.]+)\s*([KMGT]?)I?B?\z/) or return nil
|
|
67
|
+
m[1].to_f * UNITS[m[2]]
|
|
68
|
+
when *NUMERIC, :id
|
|
69
|
+
Float(text.delete("%x"), exception: false)
|
|
70
|
+
else
|
|
71
|
+
text
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/r2ui/history.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
# Recent values per (series id, attribute), for sparklines.
|
|
5
|
+
class History
|
|
6
|
+
def initialize(capacity: 120)
|
|
7
|
+
@capacity = capacity
|
|
8
|
+
@series = Hash.new { |h, k| h[k] = [] }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def record(id, key, value)
|
|
12
|
+
list = @series[[id, key]]
|
|
13
|
+
list << value.to_f
|
|
14
|
+
list.shift while list.size > @capacity
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def [](id, key) = @series.fetch([id, key], [])
|
|
18
|
+
|
|
19
|
+
# Sum several series aligned at their newest value (for grouped lines).
|
|
20
|
+
def sum(ids, key)
|
|
21
|
+
lists = ids.map { |id| self[id, key] }.reject(&:empty?)
|
|
22
|
+
return [] if lists.empty?
|
|
23
|
+
|
|
24
|
+
len = lists.map(&:size).max
|
|
25
|
+
Array.new(len) do |i|
|
|
26
|
+
lists.sum { |l| l[l.size - len + i] || 0.0 }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Forget series whose ids are no longer present.
|
|
31
|
+
def prune(live_ids)
|
|
32
|
+
live = live_ids.to_set
|
|
33
|
+
@series.delete_if { |(id, _), _| !live.include?(id) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/r2ui/keys.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
# Splits raw terminal input into key names (:up, :tab, "q", ...).
|
|
5
|
+
module Keys
|
|
6
|
+
SEQUENCES = {
|
|
7
|
+
"\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left,
|
|
8
|
+
"\e[5~" => :page_up, "\e[6~" => :page_down, "\e[H" => :home, "\e[F" => :end,
|
|
9
|
+
"\e[Z" => :back_tab, "\t" => :tab, "\r" => :enter, "\n" => :enter,
|
|
10
|
+
"\x7F" => :backspace, "\b" => :backspace, "\x03" => :interrupt, "\e" => :escape
|
|
11
|
+
}.freeze
|
|
12
|
+
PATTERN = Regexp.union(*SEQUENCES.keys.sort_by { -_1.length }, /./m)
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def parse(input) = input.scan(PATTERN).map { |k| SEQUENCES.fetch(k, k) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module R2UI
|
|
6
|
+
# What the viewer has chosen for one table panel: scope, grouping, sort, search, selection.
|
|
7
|
+
class PanelState
|
|
8
|
+
attr_accessor :search
|
|
9
|
+
attr_reader :sort, :selected, :offset, :collapsed
|
|
10
|
+
|
|
11
|
+
def initialize(resource, table = nil)
|
|
12
|
+
@resource = resource
|
|
13
|
+
@scope_index = index_of(resource.scopes, table&.scope) || resource.scopes.index(resource.default_scope)
|
|
14
|
+
@group_index = index_of(resource.groupings, table&.group_by)
|
|
15
|
+
@sort = normalize_sort(table&.sort) || resource.default_sort
|
|
16
|
+
@search = ""
|
|
17
|
+
@selected = 0
|
|
18
|
+
@offset = 0
|
|
19
|
+
@collapsed = Set.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def scope = @scope_index && @resource.scopes[@scope_index]
|
|
23
|
+
|
|
24
|
+
def grouping = @group_index && @resource.groupings[@group_index]
|
|
25
|
+
|
|
26
|
+
def scope_tabs = @resource.scopes.each_with_index.map { |s, i| [s.label, i == @scope_index] }
|
|
27
|
+
|
|
28
|
+
def cycle_scope(step)
|
|
29
|
+
return if @resource.scopes.empty?
|
|
30
|
+
|
|
31
|
+
@scope_index = ((@scope_index || 0) + step) % @resource.scopes.size
|
|
32
|
+
reset_position
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Off → each grouping in turn → off.
|
|
36
|
+
def cycle_grouping
|
|
37
|
+
count = @resource.groupings.size
|
|
38
|
+
return if count.zero?
|
|
39
|
+
|
|
40
|
+
@group_index = @group_index.nil? ? 0 : (@group_index + 1 < count ? @group_index + 1 : nil)
|
|
41
|
+
reset_position
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Next column; numbers sort descending first, text ascending.
|
|
45
|
+
def cycle_sort
|
|
46
|
+
keys = @resource.columns.map(&:key)
|
|
47
|
+
current = keys.index(@sort&.first)
|
|
48
|
+
key = keys[current.nil? ? 0 : (current + 1) % keys.size]
|
|
49
|
+
@sort = [key, @resource.column(key).numeric? ? :desc : :asc]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reverse_sort
|
|
53
|
+
@sort = [@sort.first, @sort.last == :desc ? :asc : :desc] if @sort
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def move(delta, count)
|
|
57
|
+
@selected = (@selected + delta).clamp(0, [count - 1, 0].max)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def toggle(line)
|
|
61
|
+
return unless line&.expandable?
|
|
62
|
+
|
|
63
|
+
@collapsed.include?(line.id) ? @collapsed.delete(line.id) : @collapsed.add(line.id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scroll_to_selection(height, count)
|
|
67
|
+
@selected = @selected.clamp(0, [count - 1, 0].max)
|
|
68
|
+
@offset = @selected if @selected < @offset
|
|
69
|
+
@offset = @selected - height + 1 if height.positive? && @selected >= @offset + height
|
|
70
|
+
@offset = @offset.clamp(0, [count - height, 0].max)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def status
|
|
74
|
+
parts = []
|
|
75
|
+
parts << "group: #{grouping.label}" if grouping
|
|
76
|
+
parts << "sort: #{@resource.column(sort.first)&.label}#{sort.last == :desc ? "▼" : "▲"}" if sort
|
|
77
|
+
parts << "search: #{search}" unless search.empty?
|
|
78
|
+
parts.join(" ")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def reset_position
|
|
84
|
+
@selected = 0
|
|
85
|
+
@offset = 0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def index_of(list, name) = name && list.index { |item| item.name == name }
|
|
89
|
+
|
|
90
|
+
def normalize_sort(sort)
|
|
91
|
+
case sort
|
|
92
|
+
when nil then nil
|
|
93
|
+
when Array then sort
|
|
94
|
+
else [sort, @resource.column(sort)&.numeric? ? :desc : :asc]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/r2ui/query.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module R2UI
|
|
6
|
+
# One table line: a row, a group of rows, or a tree node with its subtree rolled up.
|
|
7
|
+
Line = Data.define(:id, :label, :values, :depth, :rows, :children, :collapsed) do
|
|
8
|
+
def count = rows.size
|
|
9
|
+
def expandable? = children.positive?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Turns raw rows into table lines: scope → search → group or tree → sort. Pure, so it is easy to test.
|
|
13
|
+
class Query
|
|
14
|
+
def initialize(resource, rows, scope: nil, grouping: nil, search: nil, sort: nil, collapsed: Set.new)
|
|
15
|
+
@resource = resource
|
|
16
|
+
@rows = rows
|
|
17
|
+
@scope = scope
|
|
18
|
+
@grouping = grouping
|
|
19
|
+
@search = search
|
|
20
|
+
@sort_key, @sort_dir = sort || resource.default_sort
|
|
21
|
+
@collapsed = collapsed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def lines
|
|
25
|
+
rows = @scope ? @scope.call(@rows) : @rows
|
|
26
|
+
rows = Search.new(@resource, @search).call(rows)
|
|
27
|
+
return sort(rows.map { |row| line_for(row) }) unless @grouping
|
|
28
|
+
|
|
29
|
+
@grouping.tree? ? tree(rows) : groups(rows)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def columns = @resource.columns
|
|
35
|
+
|
|
36
|
+
def line_for(row)
|
|
37
|
+
Line.new(id: @resource.identify(row), label: nil, values: columns.to_h { |c| [c.key, c.read(row)] },
|
|
38
|
+
depth: 0, rows: [row], children: 0, collapsed: false)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def groups(rows)
|
|
42
|
+
by = @grouping.by
|
|
43
|
+
reader = @resource.column(by)&.method(:read) || ->(row) { Value.fetch(row, by) }
|
|
44
|
+
grouped = rows.group_by(&reader)
|
|
45
|
+
sort(grouped.map do |key, members|
|
|
46
|
+
Line.new(id: [:group, by, key], label: key, values: aggregate(members), depth: 0, rows: members,
|
|
47
|
+
children: 0, collapsed: false)
|
|
48
|
+
end)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def aggregate(rows)
|
|
52
|
+
columns.to_h do |c|
|
|
53
|
+
values = rows.map { |r| c.read(r) }
|
|
54
|
+
value = case c.aggregate
|
|
55
|
+
when :sum then values.compact.sum
|
|
56
|
+
when :count then rows.size
|
|
57
|
+
else values.uniq.size == 1 ? values.first : nil
|
|
58
|
+
end
|
|
59
|
+
[c.key, value]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Each node's summed columns hold its whole subtree; other columns hold its own value.
|
|
64
|
+
def tree(rows)
|
|
65
|
+
by_id = rows.to_h { |r| [@resource.identify(r), r] }
|
|
66
|
+
kids = Hash.new { |h, k| h[k] = [] }
|
|
67
|
+
roots = []
|
|
68
|
+
rows.each do |row|
|
|
69
|
+
id = @resource.identify(row)
|
|
70
|
+
parent = Value.fetch(row, @resource.parent_key)
|
|
71
|
+
parent != id && by_id.key?(parent) ? kids[parent] << row : roots << row
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
subtree = {}
|
|
75
|
+
collect = lambda do |row, seen|
|
|
76
|
+
id = @resource.identify(row)
|
|
77
|
+
return [] unless seen.add?(id)
|
|
78
|
+
|
|
79
|
+
subtree[id] ||= [row] + kids[id].flat_map { |k| collect.call(k, seen) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
nodes = lambda do |siblings, depth|
|
|
83
|
+
lines = sort(siblings.map do |row|
|
|
84
|
+
id = @resource.identify(row)
|
|
85
|
+
members = subtree[id] || collect.call(row, Set.new)
|
|
86
|
+
own = line_for(row).values
|
|
87
|
+
values = aggregate(members).to_h { |k, v| [k, @resource.column(k).aggregate == :sum ? v : own[k]] }
|
|
88
|
+
Line.new(id:, label: nil, values:, depth:, rows: members, children: kids[id].size,
|
|
89
|
+
collapsed: @collapsed.include?(id))
|
|
90
|
+
end)
|
|
91
|
+
lines.flat_map do |line|
|
|
92
|
+
line.collapsed ? [line] : [line, *nodes.call(kids[line.id], depth + 1)]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
nodes.call(roots, 0)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def sort(lines)
|
|
99
|
+
return lines unless @sort_key
|
|
100
|
+
|
|
101
|
+
sorted = lines.sort_by do |line|
|
|
102
|
+
v = line.values[@sort_key]
|
|
103
|
+
v.nil? ? [1, 0] : [0, v.is_a?(Numeric) ? v : v.to_s.downcase]
|
|
104
|
+
end
|
|
105
|
+
return sorted unless @sort_dir == :desc
|
|
106
|
+
|
|
107
|
+
present, missing = sorted.partition { |l| !l.values[@sort_key].nil? }
|
|
108
|
+
present.reverse + missing
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
class Registry
|
|
5
|
+
attr_reader :resources, :dashboards
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@resources = {}
|
|
9
|
+
@dashboards = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add_resource(resource) = @resources[resource.name] = resource
|
|
13
|
+
|
|
14
|
+
def add_dashboard(dashboard) = @dashboards[dashboard.name] = dashboard
|
|
15
|
+
|
|
16
|
+
def resource(name) = @resources.fetch(name.to_sym) { raise Error, "no resource named #{name}" }
|
|
17
|
+
|
|
18
|
+
# A dashboard by name, or a resource shown full screen. With no name: :main, else the first defined.
|
|
19
|
+
def screen(name = nil)
|
|
20
|
+
name = name&.to_sym
|
|
21
|
+
return dashboards[name] if name && dashboards.key?(name)
|
|
22
|
+
return DSL::Dashboard.for_resource(resource(name)) if name
|
|
23
|
+
return dashboards[:main] if dashboards.key?(:main)
|
|
24
|
+
return dashboards.values.first if dashboards.any?
|
|
25
|
+
raise Error, "nothing to show: define a resource or dashboard" if resources.empty?
|
|
26
|
+
|
|
27
|
+
DSL::Dashboard.for_resource(resources.values.first)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
# Lays out a dashboard and draws every panel into a canvas.
|
|
5
|
+
class Renderer
|
|
6
|
+
HINTS = "tab panel [ ] scope g group s/S sort / search ⏎ fold z zoom q quit"
|
|
7
|
+
|
|
8
|
+
# The lines each table panel showed in the last frame, for selection and actions.
|
|
9
|
+
attr_reader :lines
|
|
10
|
+
|
|
11
|
+
def initialize(registry, feeds, states)
|
|
12
|
+
@registry = registry
|
|
13
|
+
@feeds = feeds
|
|
14
|
+
@states = states
|
|
15
|
+
@lines = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render(dashboard, width:, height:, focus:, zoomed: false, prompt: nil)
|
|
19
|
+
canvas = Canvas.new(width, height)
|
|
20
|
+
body = Rect.new(x: 0, y: 0, width:, height: height - 1)
|
|
21
|
+
if zoomed
|
|
22
|
+
draw_panel(canvas, body, focus, true)
|
|
23
|
+
else
|
|
24
|
+
rows_layout(dashboard, body).each do |rect, panel|
|
|
25
|
+
draw_panel(canvas, rect, panel, panel == focus)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
draw_status(canvas, height - 1, width, focus, prompt)
|
|
29
|
+
canvas
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def rows_layout(dashboard, body)
|
|
35
|
+
fixed = dashboard.rows.sum { |r| r.height || 0 }
|
|
36
|
+
flexible = dashboard.rows.count { |r| r.height.nil? }
|
|
37
|
+
share = flexible.zero? ? 0 : (body.height - fixed) / flexible
|
|
38
|
+
y = body.y
|
|
39
|
+
dashboard.rows.each_with_index.flat_map do |row, i|
|
|
40
|
+
last = i == dashboard.rows.size - 1
|
|
41
|
+
h = last ? body.bottom - y : (row.height || share)
|
|
42
|
+
rect = Rect.new(x: body.x, y:, width: body.width, height: [h, 0].max)
|
|
43
|
+
y += rect.height
|
|
44
|
+
split(rect, row.panels)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def split(rect, panels)
|
|
49
|
+
total = panels.sum(&:span)
|
|
50
|
+
x = rect.x
|
|
51
|
+
panels.each_with_index.map do |panel, i|
|
|
52
|
+
w = i == panels.size - 1 ? rect.x + rect.width - x : (rect.width * panel.span / total)
|
|
53
|
+
r = rect.with(x:, width: w)
|
|
54
|
+
x += w
|
|
55
|
+
[r, panel]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def draw_panel(canvas, rect, panel, focused)
|
|
60
|
+
resource = @registry.resource(panel.resource)
|
|
61
|
+
feed = @feeds.fetch(resource.name)
|
|
62
|
+
state = @states[panel]
|
|
63
|
+
title = panel.title || (panel.name == resource.name ? resource.title : panel.name.to_s.tr("_", " ").capitalize)
|
|
64
|
+
inner = Widgets::Box.draw(canvas, rect, title:, focused:, tabs: state ? state.scope_tabs : [])
|
|
65
|
+
return if inner.empty?
|
|
66
|
+
|
|
67
|
+
if (error = feed.error)
|
|
68
|
+
canvas.write(inner.x, inner.y, error, :alert, max: inner.width)
|
|
69
|
+
inner = inner.take(1).last
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
rows = feed.rows
|
|
73
|
+
record = rows.first
|
|
74
|
+
panel.items.each do |item|
|
|
75
|
+
break if inner.empty?
|
|
76
|
+
|
|
77
|
+
inner = case item
|
|
78
|
+
when DSL::Gauge then draw_gauge(canvas, inner, resource, record, item)
|
|
79
|
+
when DSL::Stat then draw_stats(canvas, inner, resource, record, item)
|
|
80
|
+
when DSL::Sparkline then draw_sparkline(canvas, inner, resource, feed, item)
|
|
81
|
+
when DSL::Table then draw_table(canvas, inner, resource, feed, rows, panel, state, focused, item)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def draw_gauge(canvas, rect, resource, record, item)
|
|
87
|
+
line, rest = rect.take(1)
|
|
88
|
+
return rest unless record
|
|
89
|
+
|
|
90
|
+
column = column_for(resource, item.attr)
|
|
91
|
+
Widgets::Gauge.draw(canvas, line, label: item.label || column.label, value: column.read(record),
|
|
92
|
+
total: column_for(resource, item.of).read(record), column:)
|
|
93
|
+
rest
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def draw_stats(canvas, rect, resource, record, item)
|
|
97
|
+
area, rest = rect.take(item.attrs.size)
|
|
98
|
+
return rest unless record
|
|
99
|
+
|
|
100
|
+
Widgets::Stat.draw(canvas, area, item.attrs.map { |a| column_for(resource, a).then { |c| [c, c.read(record)] } })
|
|
101
|
+
rest
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def draw_sparkline(canvas, rect, resource, feed, item)
|
|
105
|
+
area, rest = rect.take(item.height + 1)
|
|
106
|
+
column = column_for(resource, item.attr)
|
|
107
|
+
values = feed.history[Feed::SINGLE, column.key]
|
|
108
|
+
label, chart = area.take(1)
|
|
109
|
+
canvas.write(label.x, label.y, item.label || column.label, :muted)
|
|
110
|
+
now = column.render(values.last)
|
|
111
|
+
canvas.write(label.x + label.width - now.length, label.y, now, :bold)
|
|
112
|
+
Widgets::Sparkline.draw(canvas, chart, values, max: column.format == :percent ? 100 : nil, style: :accent)
|
|
113
|
+
rest
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def draw_table(canvas, rect, resource, feed, rows, panel, state, focused, item)
|
|
117
|
+
lines = Query.new(resource, rows, scope: state.scope, grouping: state.grouping, search: state.search,
|
|
118
|
+
sort: state.sort, collapsed: state.collapsed).lines
|
|
119
|
+
lines = lines.first(item.limit) if item.limit
|
|
120
|
+
@lines[panel] = lines
|
|
121
|
+
label_key = state.grouping && !state.grouping.tree? && resource.column(state.grouping.by) ? state.grouping.by : nil
|
|
122
|
+
spark = ->(line, col) { feed.history.sum(line.rows.map { |r| feed.series_id(r) }, col.key) }
|
|
123
|
+
Widgets::Table.new(resource, lines, state:, focused:, label_key:, spark:).draw(canvas, rect)
|
|
124
|
+
rect.with(height: 0)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def draw_status(canvas, y, width, focus, prompt)
|
|
128
|
+
left = prompt || @states[focus]&.status.to_s
|
|
129
|
+
canvas.fill(Rect.new(x: 0, y:, width:, height: 1), " ", :reverse)
|
|
130
|
+
canvas.write(1, y, left, :reverse, max: width - 2)
|
|
131
|
+
hints = HINTS
|
|
132
|
+
canvas.write(width - hints.length - 1, y, hints, :reverse) if left.length + hints.length + 4 <= width
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def column_for(resource, key)
|
|
136
|
+
resource.column(key) || DSL::Column.build(key)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/r2ui/search.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
# The "/" search. Space-separated terms, all must match:
|
|
5
|
+
# codex free text, case-insensitive, against the resource's `filter` attributes
|
|
6
|
+
# cpu>5 comparison on a column, operand parsed by its format ("mem>=500M")
|
|
7
|
+
# cwd~starseed substring match on one column
|
|
8
|
+
class Search
|
|
9
|
+
TERM = /\A(?<field>\w+)(?<op>>=|<=|!=|>|<|=|~)(?<operand>.+)\z/
|
|
10
|
+
|
|
11
|
+
def initialize(resource, text)
|
|
12
|
+
@resource = resource
|
|
13
|
+
@predicates = text.to_s.split.map { |term| predicate(term) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(rows) = @predicates.empty? ? rows : rows.select { |row| @predicates.all? { |p| p.call(row) } }
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def predicate(term)
|
|
21
|
+
m = TERM.match(term)
|
|
22
|
+
column = m && @resource.column(m[:field].to_sym)
|
|
23
|
+
return free_text(term) unless column
|
|
24
|
+
|
|
25
|
+
compare(column, m[:op], m[:operand])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def free_text(term)
|
|
29
|
+
needle = term.downcase
|
|
30
|
+
keys = @resource.searchable.any? ? @resource.searchable : text_columns
|
|
31
|
+
readers = keys.map { |k| @resource.column(k)&.method(:read) || ->(row) { Value.fetch(row, k) } }
|
|
32
|
+
->(row) { readers.any? { |read| read.call(row).to_s.downcase.include?(needle) } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def compare(column, op, operand)
|
|
36
|
+
return ->(row) { column.read(row).to_s.downcase.include?(operand.downcase) } if op == "~"
|
|
37
|
+
|
|
38
|
+
target = Format.parse(column.format, operand)
|
|
39
|
+
return ->(_) { false } if target.nil?
|
|
40
|
+
|
|
41
|
+
lambda do |row|
|
|
42
|
+
value = column.read(row)
|
|
43
|
+
value = value.to_s.downcase if target.is_a?(String)
|
|
44
|
+
target = target.downcase if target.is_a?(String)
|
|
45
|
+
case op
|
|
46
|
+
when ">" then value > target
|
|
47
|
+
when "<" then value < target
|
|
48
|
+
when ">=" then value >= target
|
|
49
|
+
when "<=" then value <= target
|
|
50
|
+
when "=" then value == target
|
|
51
|
+
when "!=" then value != target
|
|
52
|
+
end
|
|
53
|
+
rescue ArgumentError, NoMethodError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def text_columns = @resource.columns.reject(&:numeric?).map(&:key)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module R2UI
|
|
6
|
+
# Raw mode, alternate screen, line-diffed redraws. Always restores the terminal on exit.
|
|
7
|
+
class Terminal
|
|
8
|
+
def initialize(input: $stdin, output: $stdout)
|
|
9
|
+
@in = input
|
|
10
|
+
@out = output
|
|
11
|
+
@previous = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def open
|
|
15
|
+
@in.raw!
|
|
16
|
+
@out.write("\e[?1049h\e[?25l\e[2J")
|
|
17
|
+
@out.flush
|
|
18
|
+
yield self
|
|
19
|
+
ensure
|
|
20
|
+
@out.write("\e[0m\e[?25h\e[?1049l")
|
|
21
|
+
@out.flush
|
|
22
|
+
@in.cooked!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def size
|
|
26
|
+
rows, cols = @out.winsize
|
|
27
|
+
[cols, rows]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clear = @previous = []
|
|
31
|
+
|
|
32
|
+
def draw(canvas)
|
|
33
|
+
out = +""
|
|
34
|
+
lines = canvas.ansi_lines
|
|
35
|
+
lines.each_with_index do |line, y|
|
|
36
|
+
next if @previous[y] == line
|
|
37
|
+
|
|
38
|
+
out << "\e[#{y + 1};1H" << line
|
|
39
|
+
end
|
|
40
|
+
@previous = lines
|
|
41
|
+
@out.write(out)
|
|
42
|
+
@out.flush
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Key names typed within `timeout` seconds (empty if none).
|
|
46
|
+
def read_keys(timeout)
|
|
47
|
+
return [] unless @in.wait_readable(timeout)
|
|
48
|
+
|
|
49
|
+
data = @in.read_nonblock(64, exception: false)
|
|
50
|
+
data.is_a?(String) ? Keys.parse(data) : []
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/r2ui/value.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
# Rows can be hashes, Data/Struct objects or ActiveRecord models; read them all the same way.
|
|
5
|
+
module Value
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def fetch(row, key)
|
|
9
|
+
case row
|
|
10
|
+
when Hash then row.key?(key) ? row[key] : row[key.to_s]
|
|
11
|
+
else row.public_send(key)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/r2ui/version.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module Widgets
|
|
5
|
+
# A bordered panel with a title on the top edge. Returns the inner rect.
|
|
6
|
+
module Box
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def draw(canvas, rect, title: nil, focused: false, tabs: [])
|
|
10
|
+
return rect if rect.width < 2 || rect.height < 2
|
|
11
|
+
|
|
12
|
+
style = focused ? :focus : :border
|
|
13
|
+
right = rect.x + rect.width - 1
|
|
14
|
+
canvas.write(rect.x, rect.y, "╭#{"─" * (rect.width - 2)}╮", style)
|
|
15
|
+
(rect.height - 2).times do |i|
|
|
16
|
+
canvas.write(rect.x, rect.y + 1 + i, "│", style)
|
|
17
|
+
canvas.write(right, rect.y + 1 + i, "│", style)
|
|
18
|
+
end
|
|
19
|
+
canvas.write(rect.x, rect.bottom - 1, "╰#{"─" * (rect.width - 2)}╯", style)
|
|
20
|
+
|
|
21
|
+
x = rect.x + 2
|
|
22
|
+
max = right - 1
|
|
23
|
+
if title
|
|
24
|
+
canvas.write(x, rect.y, " #{title} ", :title, max: max - x)
|
|
25
|
+
x += title.length + 3
|
|
26
|
+
end
|
|
27
|
+
tabs.each do |label, active|
|
|
28
|
+
break if x >= max
|
|
29
|
+
|
|
30
|
+
canvas.write(x, rect.y, active ? "[#{label}]" : " #{label} ", active ? :accent : :muted, max: max - x)
|
|
31
|
+
x += label.length + 2
|
|
32
|
+
end
|
|
33
|
+
rect.inner
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|