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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module Widgets
|
|
5
|
+
# "Used ▕██████░░░░▏ 21G / 32G 67%" on one line.
|
|
6
|
+
module Gauge
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def draw(canvas, rect, label:, value:, total:, column:)
|
|
10
|
+
return if rect.empty?
|
|
11
|
+
|
|
12
|
+
fraction = total.to_f.positive? ? (value.to_f / total).clamp(0, 1) : 0
|
|
13
|
+
right = "#{column.render(value)} / #{column.render(total)} #{(fraction * 100).round}%"
|
|
14
|
+
label = label.ljust(10)
|
|
15
|
+
bar_w = rect.width - label.length - right.length - 4
|
|
16
|
+
canvas.write(rect.x, rect.y, label, :bold)
|
|
17
|
+
if bar_w >= 4
|
|
18
|
+
filled = (fraction * bar_w).round
|
|
19
|
+
canvas.write(rect.x + label.length, rect.y, "▕", :muted)
|
|
20
|
+
canvas.write(rect.x + label.length + 1, rect.y, "█" * filled, style(fraction))
|
|
21
|
+
canvas.write(rect.x + label.length + 1 + filled, rect.y, "░" * (bar_w - filled), :muted)
|
|
22
|
+
canvas.write(rect.x + label.length + 1 + bar_w, rect.y, "▏", :muted)
|
|
23
|
+
end
|
|
24
|
+
canvas.write(rect.x + rect.width - right.length, rect.y, right)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def style(fraction)
|
|
28
|
+
if fraction >= 0.9 then :alert
|
|
29
|
+
elsif fraction >= 0.7 then :warn
|
|
30
|
+
else :ok
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module Widgets
|
|
5
|
+
module Sparkline
|
|
6
|
+
BARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
|
|
7
|
+
# Eighths of a full cell, for multi-line charts.
|
|
8
|
+
BLOCKS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# One line of bars for the newest `width` values, scaled to the window's max (or `max:`).
|
|
13
|
+
def line(values, width, max: nil)
|
|
14
|
+
window = values.last(width).map(&:to_f)
|
|
15
|
+
return "" if window.empty?
|
|
16
|
+
|
|
17
|
+
top = max || window.max
|
|
18
|
+
top = 1.0 if top.nil? || top <= 0
|
|
19
|
+
window.map { |v| BARS[((v / top) * (BARS.size - 1)).round.clamp(0, BARS.size - 1)] }.join
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A chart `rect.height` lines tall, newest value at the right edge.
|
|
23
|
+
def draw(canvas, rect, values, max: nil, style: :ok)
|
|
24
|
+
return if rect.empty?
|
|
25
|
+
|
|
26
|
+
window = values.last(rect.width).map(&:to_f)
|
|
27
|
+
top = max || window.max
|
|
28
|
+
top = 1.0 if top.nil? || top <= 0
|
|
29
|
+
offset = rect.width - window.size
|
|
30
|
+
window.each_with_index do |v, i|
|
|
31
|
+
eighths = ((v / top).clamp(0, 1) * rect.height * 8).round
|
|
32
|
+
rect.height.times do |row|
|
|
33
|
+
fill = (eighths - (row * 8)).clamp(0, 8)
|
|
34
|
+
next if fill.zero?
|
|
35
|
+
|
|
36
|
+
canvas.write(rect.x + offset + i, rect.bottom - 1 - row, BLOCKS[fill], style)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Label on the left, formatted value on the right, one per line.
|
|
6
|
+
module Stat
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def draw(canvas, rect, pairs)
|
|
10
|
+
pairs.first(rect.height).each_with_index do |(column, value), i|
|
|
11
|
+
text = column.render(value)
|
|
12
|
+
canvas.write(rect.x, rect.y + i, column.label, :muted, max: rect.width - text.length - 1)
|
|
13
|
+
canvas.write(rect.x + rect.width - text.length, rect.y + i, text, :bold)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module Widgets
|
|
5
|
+
# The index table: header, sort marker, grouped/tree labels, inline sparklines, selection.
|
|
6
|
+
class Table
|
|
7
|
+
SPARK_WIDTH = 10
|
|
8
|
+
MIN_TEXT = 8
|
|
9
|
+
|
|
10
|
+
# `spark` is called with (line, column) and returns that line's recent values.
|
|
11
|
+
def initialize(resource, lines, state:, focused:, label_key: nil, spark: nil)
|
|
12
|
+
@resource = resource
|
|
13
|
+
@lines = lines
|
|
14
|
+
@state = state
|
|
15
|
+
@focused = focused
|
|
16
|
+
@spark = spark
|
|
17
|
+
@label_key = label_key || resource.columns.find { |c| !c.numeric? && c.format != :id }&.key
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def draw(canvas, rect)
|
|
21
|
+
return if rect.empty?
|
|
22
|
+
|
|
23
|
+
layout = widths(rect.width)
|
|
24
|
+
draw_header(canvas, rect, layout)
|
|
25
|
+
body = rect.with(y: rect.y + 1, height: rect.height - 1)
|
|
26
|
+
@state.scroll_to_selection(body.height, @lines.size)
|
|
27
|
+
@lines.drop(@state.offset).first(body.height).each_with_index do |line, i|
|
|
28
|
+
index = @state.offset + i
|
|
29
|
+
selected = @focused && index == @state.selected
|
|
30
|
+
canvas.fill(body.with(y: body.y + i, height: 1), " ", :selected) if selected
|
|
31
|
+
draw_line(canvas, rect.x, body.y + i, line, layout, selected)
|
|
32
|
+
end
|
|
33
|
+
draw_footer(canvas, rect) if @lines.size > body.height
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def draw_header(canvas, rect, layout)
|
|
39
|
+
sort_key, sort_dir = @state.sort
|
|
40
|
+
x = rect.x
|
|
41
|
+
layout.each do |col, w|
|
|
42
|
+
label = col.label
|
|
43
|
+
label = "#{label}#{sort_dir == :desc ? "▼" : "▲"}" if col.key == sort_key
|
|
44
|
+
canvas.write(x, rect.y, align(label, w, col.align), :header, max: w)
|
|
45
|
+
x += w + 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def draw_line(canvas, x, y, line, layout, selected)
|
|
50
|
+
layout.each do |col, w|
|
|
51
|
+
text, style = cell(line, col, w)
|
|
52
|
+
canvas.write(x, y, text, selected ? :selected : style, max: w)
|
|
53
|
+
x += w + 1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cell(line, col, width)
|
|
58
|
+
if col.key == @label_key && (line.label || line.depth.positive? || line.expandable?)
|
|
59
|
+
prefix, body, suffix = label_parts(line, col)
|
|
60
|
+
body = fit(body, [width - prefix.length - suffix.length, 1].max, Format.truncate_from(col.format))
|
|
61
|
+
return [fit("#{prefix}#{body}#{suffix}", width, :right), line.label ? :bold : :plain]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
value = line.values[col.key]
|
|
65
|
+
value = "×#{line.count}" if col.aggregate == :count && line.label
|
|
66
|
+
text = value.is_a?(String) && col.aggregate == :count ? value : col.render(value)
|
|
67
|
+
if col.sparkline && @spark
|
|
68
|
+
text_w = width - SPARK_WIDTH - 1
|
|
69
|
+
text = "#{Sparkline.line(@spark.call(line, col), SPARK_WIDTH).rjust(SPARK_WIDTH)} #{align(text, text_w, col.align)}"
|
|
70
|
+
return [text, :plain]
|
|
71
|
+
end
|
|
72
|
+
[align(fit(text, width, Format.truncate_from(col.format)), width, col.align), style_for(col, value)]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# [indent and tree marker, the value (truncated to fit), count suffix]
|
|
76
|
+
def label_parts(line, col)
|
|
77
|
+
if line.label
|
|
78
|
+
body = Format.call(col.format, line.label)
|
|
79
|
+
counted = @resource.columns.any? { |c| c.aggregate == :count }
|
|
80
|
+
["", body.empty? ? "(none)" : body, counted ? "" : " ×#{line.count}"]
|
|
81
|
+
else
|
|
82
|
+
marker = if line.expandable? then line.collapsed ? "▸ " : "▾ " else " " end
|
|
83
|
+
["#{" " * line.depth}#{marker}", col.render(line.values[col.key]), line.count > 1 ? " (#{line.count})" : ""]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def style_for(col, value)
|
|
88
|
+
return :plain unless col.format == :percent && value.is_a?(Numeric)
|
|
89
|
+
|
|
90
|
+
if value >= 80 then :alert
|
|
91
|
+
elsif value >= 25 then :warn
|
|
92
|
+
else :plain
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Fixed-width columns get their width; text columns share the rest. Drops columns that don't fit.
|
|
97
|
+
def widths(total)
|
|
98
|
+
cols = @resource.columns
|
|
99
|
+
fixed = cols.to_h do |c|
|
|
100
|
+
w = c.width || Format.default_width(c.format)
|
|
101
|
+
w &&= [w, c.label.length + 1].max
|
|
102
|
+
w += SPARK_WIDTH + 1 if w && c.sparkline
|
|
103
|
+
[c, w]
|
|
104
|
+
end
|
|
105
|
+
loop do
|
|
106
|
+
flexible = fixed.count { |_, w| w.nil? }
|
|
107
|
+
used = fixed.values.compact.sum + fixed.size - 1
|
|
108
|
+
spare = total - used
|
|
109
|
+
break if flexible.zero? ? spare >= 0 : spare >= flexible * MIN_TEXT
|
|
110
|
+
break if fixed.size == 1
|
|
111
|
+
|
|
112
|
+
fixed.delete(fixed.keys.last)
|
|
113
|
+
end
|
|
114
|
+
flexible = fixed.count { |_, w| w.nil? }
|
|
115
|
+
spare = total - fixed.values.compact.sum - (fixed.size - 1)
|
|
116
|
+
fixed.to_h { |c, w| [c, w || [spare / [flexible, 1].max, 1].max] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def draw_footer(canvas, rect)
|
|
120
|
+
text = " #{@state.selected + 1}/#{@lines.size} "
|
|
121
|
+
canvas.write(rect.x + rect.width - text.length, rect.bottom, text, :muted)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def fit(text, width, from)
|
|
125
|
+
return text if text.length <= width
|
|
126
|
+
return text[0, width] if width < 2
|
|
127
|
+
|
|
128
|
+
from == :left ? "…#{text[-(width - 1)..]}" : "#{text[0, width - 1]}…"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def align(text, width, side) = side == :right ? text.rjust(width) : text.ljust(width)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/lib/r2ui.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "r2ui/version"
|
|
4
|
+
require_relative "r2ui/value"
|
|
5
|
+
require_relative "r2ui/format"
|
|
6
|
+
require_relative "r2ui/history"
|
|
7
|
+
require_relative "r2ui/dsl/column"
|
|
8
|
+
require_relative "r2ui/dsl/resource"
|
|
9
|
+
require_relative "r2ui/dsl/dashboard"
|
|
10
|
+
require_relative "r2ui/registry"
|
|
11
|
+
require_relative "r2ui/search"
|
|
12
|
+
require_relative "r2ui/query"
|
|
13
|
+
require_relative "r2ui/canvas"
|
|
14
|
+
require_relative "r2ui/widgets/box"
|
|
15
|
+
require_relative "r2ui/widgets/table"
|
|
16
|
+
require_relative "r2ui/widgets/gauge"
|
|
17
|
+
require_relative "r2ui/widgets/stat"
|
|
18
|
+
require_relative "r2ui/widgets/sparkline"
|
|
19
|
+
require_relative "r2ui/panel_state"
|
|
20
|
+
require_relative "r2ui/feed"
|
|
21
|
+
require_relative "r2ui/renderer"
|
|
22
|
+
require_relative "r2ui/keys"
|
|
23
|
+
require_relative "r2ui/terminal"
|
|
24
|
+
require_relative "r2ui/app"
|
|
25
|
+
|
|
26
|
+
# R2UI: declare terminal dashboards the way ActiveAdmin declares admin pages.
|
|
27
|
+
#
|
|
28
|
+
# R2UI.resource :process do
|
|
29
|
+
# source { MyProbe.processes }
|
|
30
|
+
# scope :all, default: true
|
|
31
|
+
# group_by :name
|
|
32
|
+
# index { column :pid, format: :id; column :cpu, format: :percent }
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# R2UI.run
|
|
36
|
+
module R2UI
|
|
37
|
+
class Error < StandardError; end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
def registry = @registry ||= Registry.new
|
|
41
|
+
|
|
42
|
+
def resource(name, &) = registry.add_resource(DSL::Resource.build(name, &))
|
|
43
|
+
|
|
44
|
+
def dashboard(name = :main, &) = registry.add_dashboard(DSL::Dashboard.build(name, &))
|
|
45
|
+
|
|
46
|
+
def run(name = nil) = App.new(registry, name).run
|
|
47
|
+
|
|
48
|
+
# One frame as plain text, without a terminal. Used by `r2ui --snapshot` and tests.
|
|
49
|
+
def snapshot(name = nil, width: 120, height: 40, ticks: 1) = App.new(registry, name).snapshot(width:, height:, ticks:)
|
|
50
|
+
|
|
51
|
+
def reset! = @registry = Registry.new
|
|
52
|
+
end
|
|
53
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: r2ui
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ryan Gavin
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Declare resources, scopes, groupings, columns and actions; R2UI draws
|
|
13
|
+
the tables, charts and key bindings in your terminal.
|
|
14
|
+
email:
|
|
15
|
+
- ryan.michael.gavin@gmail.com
|
|
16
|
+
executables:
|
|
17
|
+
- r2ui
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE.txt
|
|
22
|
+
- README.md
|
|
23
|
+
- exe/r2ui
|
|
24
|
+
- lib/r2ui.rb
|
|
25
|
+
- lib/r2ui/app.rb
|
|
26
|
+
- lib/r2ui/canvas.rb
|
|
27
|
+
- lib/r2ui/dsl/column.rb
|
|
28
|
+
- lib/r2ui/dsl/dashboard.rb
|
|
29
|
+
- lib/r2ui/dsl/resource.rb
|
|
30
|
+
- lib/r2ui/feed.rb
|
|
31
|
+
- lib/r2ui/format.rb
|
|
32
|
+
- lib/r2ui/history.rb
|
|
33
|
+
- lib/r2ui/keys.rb
|
|
34
|
+
- lib/r2ui/panel_state.rb
|
|
35
|
+
- lib/r2ui/query.rb
|
|
36
|
+
- lib/r2ui/registry.rb
|
|
37
|
+
- lib/r2ui/renderer.rb
|
|
38
|
+
- lib/r2ui/search.rb
|
|
39
|
+
- lib/r2ui/terminal.rb
|
|
40
|
+
- lib/r2ui/value.rb
|
|
41
|
+
- lib/r2ui/version.rb
|
|
42
|
+
- lib/r2ui/widgets/box.rb
|
|
43
|
+
- lib/r2ui/widgets/gauge.rb
|
|
44
|
+
- lib/r2ui/widgets/sparkline.rb
|
|
45
|
+
- lib/r2ui/widgets/stat.rb
|
|
46
|
+
- lib/r2ui/widgets/table.rb
|
|
47
|
+
homepage: https://github.com/satoramoto/r2ui
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
source_code_uri: https://github.com/satoramoto/r2ui
|
|
52
|
+
changelog_uri: https://github.com/satoramoto/r2ui/commits/main
|
|
53
|
+
rdoc_options: []
|
|
54
|
+
require_paths:
|
|
55
|
+
- lib
|
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.3'
|
|
61
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0'
|
|
66
|
+
requirements: []
|
|
67
|
+
rubygems_version: 3.7.1
|
|
68
|
+
specification_version: 4
|
|
69
|
+
summary: ActiveAdmin-style DSL for terminal dashboards
|
|
70
|
+
test_files: []
|