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 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
@@ -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