sidekiq 8.1.0 → 8.1.2

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.
@@ -0,0 +1,53 @@
1
+ module Sidekiq
2
+ class TUI
3
+ module Controls
4
+ # Defines data for input handling and for displaying controls.
5
+ # :code is the key code for input handling.
6
+ # :display and :description are shown in the controls area, with different
7
+ # styling between them. If :display is omitted, :code is displayed instead.
8
+ # :action is a lambda to execute when the control is triggered.
9
+ # :refresh means the action requires immediate refreshing of data
10
+ #
11
+ # Conventions: dangerous/irreversible actions should use UPPERCASE codes.
12
+ # The Shift button means "I'm sure".
13
+ GLOBAL = [
14
+ {code: "?", display: "?", description: "Help", action: ->(tui, tab) { tui.show_help }},
15
+ {code: "left", display: "←/→", description: "Select Tab", action: ->(tui, tab) { tui.navigate(:left) }, refresh: true},
16
+ {code: "right", action: ->(tui, tab) { tui.navigate(:right) }, refresh: true},
17
+ {code: "q", description: "Quit", action: ->(tui, tab) { :quit }},
18
+ {code: "c", modifiers: ["ctrl"], action: ->(tui, tab) { :quit }}
19
+ ].freeze
20
+
21
+ SHARED = {
22
+ pageable: [
23
+ {code: "h", display: "h/l", description: "Prev/Next Page",
24
+ action: ->(tui, tab) { tab.prev_page }, refresh: true},
25
+ {code: "l", action: ->(tui, tab) { tab.next_page }, refresh: true}
26
+ ],
27
+ selectable: [
28
+ {code: "k", display: "j/k", description: "Prev/Next Row",
29
+ action: ->(tui, tab) { tab.navigate_row(:up) }},
30
+ {code: "j", action: ->(tui, tab) { tab.navigate_row(:down) }},
31
+ {code: "x", description: "Select", action: ->(tui, tab) { tab.toggle_select }},
32
+ {code: "A", modifiers: ["shift"], display: "A", description: "Select All",
33
+ action: ->(tui, tab) { tab.toggle_select(:all) }}
34
+ ],
35
+ filterable: [
36
+ {code: "/", display: "/", description: "Filter", action: ->(tui, tab) { tab.start_filtering }},
37
+ {code: "backspace", action: ->(tui, tab) { tab.remove_last_char_from_filter }, refresh: true},
38
+ {code: "enter", action: ->(tui, tab) { tab.stop_filtering }, refresh: true},
39
+ {code: "esc", action: ->(tui, tab) { tab.stop_and_clear_filtering }, refresh: true}
40
+ ]
41
+ }.freeze
42
+
43
+ # Returns an array of symbols for functionality which this tab implements
44
+ def features
45
+ []
46
+ end
47
+
48
+ def controls
49
+ GLOBAL + SHARED.slice(*features).values.flatten
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,53 @@
1
+ module Sidekiq
2
+ class TUI
3
+ module Filtering
4
+ def filtering?
5
+ @data[:filtering]
6
+ end
7
+
8
+ def current_filter
9
+ @data[:filter]
10
+ end
11
+
12
+ def start_filtering
13
+ @data[:filtering] = true
14
+ @data[:filter] = ""
15
+ end
16
+
17
+ def stop_filtering
18
+ return unless @data[:filtering]
19
+
20
+ @data[:filtering] = false
21
+ @data[:selected] = []
22
+ end
23
+
24
+ def stop_and_clear_filtering
25
+ return unless @data[:filtering]
26
+
27
+ @data[:filtering] = false
28
+ @data[:filter] = nil
29
+ @data[:selected] = []
30
+ on_filter_change
31
+ end
32
+
33
+ def remove_last_char_from_filter
34
+ return unless @data[:filtering]
35
+
36
+ @data[:filter] = @data[:filter].empty? ? "" : @data[:filter][0..-2]
37
+ on_filter_change
38
+ end
39
+
40
+ def append_to_filter(string)
41
+ return unless @data[:filtering]
42
+
43
+ @data[:filter] += string
44
+ @data[:selected] = []
45
+ on_filter_change
46
+ end
47
+
48
+ def on_filter_change
49
+ # callback for subclasses
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,187 @@
1
+ module Sidekiq
2
+ class TUI
3
+ class BaseTab
4
+ include Controls
5
+
6
+ attr_reader :name
7
+ attr_reader :data
8
+
9
+ def initialize(parent)
10
+ @parent = parent
11
+ @name = self.class.name.split("::").last
12
+ reset_data
13
+ end
14
+
15
+ def t(*)
16
+ @parent.t(*)
17
+ end
18
+
19
+ def reset_data
20
+ @data = {selected: [], selected_row_index: 0}
21
+ end
22
+
23
+ def error
24
+ @data[:error]
25
+ end
26
+
27
+ def error=(e)
28
+ @data[:error] = e
29
+ end
30
+
31
+ def selected?(entry)
32
+ @data[:selected].index(entry.id)
33
+ end
34
+
35
+ def filtering?
36
+ false
37
+ end
38
+
39
+ def each_selection(unselect: true, &)
40
+ sel = @data[:selected]
41
+ finished = []
42
+ if !sel.empty?
43
+ sel.each do |id|
44
+ yield id
45
+ # When processing multiple items in bulk, we want to unselect
46
+ # each row if its operation succeeds so our UI will not
47
+ # re-process rows 1-3 if row 4 fails.
48
+ finished << id
49
+ end
50
+ else
51
+ ids = @data.dig(:table, :row_ids)
52
+ return if !ids || ids.empty?
53
+ yield ids[@data[:selected_row_index]]
54
+ end
55
+ ensure
56
+ @data[:selected] = sel - finished if unselect
57
+ end
58
+
59
+ # Navigate the row selection up or down in the current tab's table.
60
+ # @param direction [Symbol] :up or :down
61
+ def navigate_row(direction)
62
+ ids = @data.dig(:table, :row_ids)
63
+ return if !ids || ids.empty?
64
+
65
+ index_change = (direction == :down) ? 1 : -1
66
+ @data[:selected_row_index] = (@data[:selected_row_index] + index_change) % ids.count
67
+ end
68
+
69
+ def prev_page
70
+ opts = @data.dig(:table, :pager)
71
+ return unless opts
72
+ return if opts.page < 2
73
+
74
+ @data[:table][:pager] = Sidekiq::TUI::PageOptions.new(opts.page - 1, opts.size)
75
+ end
76
+
77
+ def next_page
78
+ np = @data.dig(:table, :next_page)
79
+ return unless np
80
+ opts = @data.dig(:table, :pager)
81
+ return unless opts
82
+
83
+ @data[:table][:pager] = Sidekiq::TUI::PageOptions.new(np, opts.size)
84
+ end
85
+
86
+ def toggle_select(which = :current)
87
+ sel = @data[:selected]
88
+ # log(which, sel)
89
+ if which == :current
90
+ x = @data[:table][:row_ids][@data[:selected_row_index]]
91
+ if sel.index(x)
92
+ # already checked, uncheck it
93
+ sel.delete(x)
94
+ else
95
+ sel << x
96
+ end
97
+ elsif sel.empty?
98
+ @data[:selected] = @data[:table][:row_ids]
99
+ else
100
+ sel.clear
101
+ end
102
+ end
103
+
104
+ def refresh_data_for_stats
105
+ stats = Sidekiq::Stats.new
106
+ @data[:stats] = {
107
+ processed: stats.processed,
108
+ failed: stats.failed,
109
+ busy: stats.workers_size,
110
+ enqueued: stats.enqueued,
111
+ retries: stats.retry_size,
112
+ scheduled: stats.scheduled_size,
113
+ dead: stats.dead_size
114
+ }
115
+ end
116
+
117
+ def render_table(tui, frame, area)
118
+ page = @data.dig(:table, :current_page) || 1
119
+ rows = @data.dig(:table, :rows) || []
120
+ total = @data.dig(:table, :total) || 0
121
+ footer = ["", "Page: #{page}", "Count: #{rows.size}", "Total: #{total}"]
122
+ footer << "Selected: #{@data[:selected].size}" unless @data[:selected].empty?
123
+
124
+ defaults = {
125
+ title: "TableName",
126
+ footer: footer
127
+ }
128
+ if features.include?(:selectable)
129
+ defaults.merge!({
130
+ highlight_symbol: "➡️",
131
+ selected_row: @data[:selected_row_index],
132
+ row_highlight_style: tui.style(fg: :white, bg: :blue)
133
+ })
134
+ end
135
+ hash = defaults.merge(yield)
136
+ hash[:block] ||= tui.block(title: hash.delete(:title), borders: :all)
137
+ table = tui.table(**hash)
138
+ frame.render_widget(table, area)
139
+ end
140
+
141
+ def render_stats_section(tui, frame, area)
142
+ stats = @data[:stats]
143
+
144
+ keys = ["Processed", "Failed", "Busy", "Enqueued", "Retries", "Scheduled", "Dead"]
145
+ values = [
146
+ stats[:processed],
147
+ stats[:failed],
148
+ stats[:busy],
149
+ stats[:enqueued],
150
+ stats[:retries],
151
+ stats[:scheduled],
152
+ stats[:dead]
153
+ ]
154
+
155
+ # Format keys and values with spacing
156
+ keys_line = keys.map { |k| t(k).to_s.ljust(12) }.join(" ")
157
+ values_line = values.map { |v| v.to_s.ljust(12) }.join(" ")
158
+
159
+ frame.render_widget(
160
+ tui.paragraph(
161
+ text: [keys_line, values_line],
162
+ block: tui.block(title: "Statistics", borders: [:all])
163
+ ),
164
+ area
165
+ )
166
+ end
167
+
168
+ # TODO Implement I18n delimiter
169
+ def number_with_delimiter(number, options = {})
170
+ precision = options[:precision] || 0
171
+ number.round(precision)
172
+ end
173
+
174
+ def format_memory(rss_kb)
175
+ return "0" if rss_kb.nil? || rss_kb == 0
176
+
177
+ if rss_kb < 100_000
178
+ "#{number_with_delimiter(rss_kb)} KB"
179
+ elsif rss_kb < 10_000_000
180
+ "#{number_with_delimiter((rss_kb / 1024.0).to_i)} MB"
181
+ else
182
+ "#{number_with_delimiter(rss_kb / (1024.0 * 1024.0), precision: 1)} GB"
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,118 @@
1
+ require_relative "base_tab"
2
+
3
+ module Sidekiq
4
+ class TUI
5
+ module Tabs
6
+ class Busy < BaseTab
7
+ def features
8
+ %i[selectable]
9
+ end
10
+
11
+ def controls
12
+ @controls ||= super + [
13
+ {code: "T", modifiers: ["shift"], description: "Terminate", action: ->(tui, tab) { tab.terminate! }},
14
+ {code: "Q", modifiers: ["shift"], description: "Quiet", action: ->(tui, tab) { tab.quiet! }}
15
+ ]
16
+ end
17
+
18
+ def quiet!
19
+ each_selection do |id|
20
+ Sidekiq::Process.new("identity" => id).quiet!
21
+ end
22
+ end
23
+
24
+ def terminate!
25
+ each_selection do |id|
26
+ Sidekiq::Process.new("identity" => id).stop!
27
+ end
28
+ end
29
+
30
+ def refresh_data
31
+ refresh_data_for_stats
32
+
33
+ busy = []
34
+ table_row_ids = []
35
+
36
+ Sidekiq::ProcessSet.new.each do |p|
37
+ name = "#{p["hostname"]}:#{p["pid"]}"
38
+ name += " ⭐️" if p.leader?
39
+ name += " 🛑" if p.stopping?
40
+ busy << [
41
+ selected?(p) ? "✅" : "",
42
+ name,
43
+ Time.at(p["started_at"]).utc,
44
+ format_memory(p["rss"].to_i),
45
+ number_with_delimiter(p["concurrency"]),
46
+ number_with_delimiter(p["busy"])
47
+ ]
48
+ table_row_ids << p.identity
49
+ end
50
+
51
+ @data[:busy] = busy
52
+ @data[:table] = {row_ids: table_row_ids}
53
+ end
54
+
55
+ def render(tui, frame, area)
56
+ chunks = tui.layout_split(
57
+ area,
58
+ direction: :vertical,
59
+ constraints: [
60
+ tui.constraint_length(4), # Stats
61
+ tui.constraint_length(4), # Status
62
+ tui.constraint_fill(1) # Graph
63
+ ]
64
+ )
65
+
66
+ render_stats_section(tui, frame, chunks[0])
67
+ render_status_section(tui, frame, chunks[1])
68
+ render_table(tui, frame, chunks[2]) do
69
+ {
70
+ title: t("Processes"),
71
+ header: ["☑️", "Name", "Started", "RSS", "Threads", "Busy"].map { |x| t(x) },
72
+ widths: [
73
+ tui.constraint_length(5),
74
+ tui.constraint_fill(1),
75
+ tui.constraint_length(24),
76
+ tui.constraint_length(10),
77
+ tui.constraint_length(6),
78
+ tui.constraint_length(6)
79
+ ],
80
+ rows: @data[:busy].map.with_index { |cells, idx|
81
+ tui.table_row(
82
+ cells:,
83
+ style: idx.even? ? nil : tui.style(bg: :dark_gray)
84
+ )
85
+ }
86
+ }
87
+ end
88
+ end
89
+
90
+ def render_status_section(tui, frame, area)
91
+ values = []
92
+ processes = Sidekiq::ProcessSet.new
93
+ workset = Sidekiq::WorkSet.new
94
+ ws = workset.size
95
+ values << (s = processes.size
96
+ number_with_delimiter(s))
97
+ values << (x = processes.total_concurrency
98
+ number_with_delimiter(x))
99
+ values << number_with_delimiter(ws)
100
+ values << "#{(x == 0) ? 0 : ((ws / x.to_f) * 100).round(0)}%"
101
+ values << format_memory(processes.total_rss)
102
+
103
+ keys = %w[Processes Threads Busy Utilization RSS]
104
+ keys_line = keys.map { |k| t(k).to_s.ljust(12) }.join(" ")
105
+ values_line = values.map { |v| v.to_s.ljust(12) }.join(" ")
106
+
107
+ frame.render_widget(
108
+ tui.paragraph(
109
+ text: [keys_line, values_line],
110
+ block: tui.block(title: t("Status"), borders: [:all])
111
+ ),
112
+ area
113
+ )
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "base_tab"
2
+ require_relative "set_tab"
3
+
4
+ module Sidekiq
5
+ class TUI
6
+ module Tabs
7
+ class Dead < BaseTab
8
+ include SetTab
9
+
10
+ def set_class = Sidekiq::DeadSet
11
+
12
+ def refresh_data
13
+ refresh_data_for_stats
14
+ refresh_data_for_set
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,144 @@
1
+ require_relative "base_tab"
2
+
3
+ module Sidekiq
4
+ class TUI
5
+ module Tabs
6
+ class Home < BaseTab
7
+ def refresh_data
8
+ refresh_data_for_stats
9
+
10
+ stats = Sidekiq::Stats.new
11
+ @data[:chart] ||= {
12
+ previous_stats: {
13
+ processed: stats.processed,
14
+ failed: stats.failed
15
+ },
16
+ deltas: {
17
+ processed: Array.new(50, 0),
18
+ failed: Array.new(50, 0)
19
+ }
20
+ }
21
+
22
+ processed_delta = stats.processed - @data[:chart][:previous_stats][:processed]
23
+ failed_delta = stats.failed - @data[:chart][:previous_stats][:failed]
24
+
25
+ @data[:chart][:deltas][:processed].shift
26
+ @data[:chart][:deltas][:processed].push(processed_delta)
27
+ @data[:chart][:deltas][:failed].shift
28
+ @data[:chart][:deltas][:failed].push(failed_delta)
29
+
30
+ @data[:chart][:previous_stats] = {
31
+ processed: stats.processed,
32
+ failed: stats.failed
33
+ }
34
+
35
+ redis_info = Sidekiq.default_configuration.redis_info
36
+
37
+ @data[:redis_info] = {
38
+ version: redis_info["redis_version"] || "N/A",
39
+ uptime_days: redis_info["uptime_in_days"] || "N/A",
40
+ connected_clients: redis_info["connected_clients"] || "N/A",
41
+ used_memory: redis_info["used_memory_human"] || "N/A",
42
+ peak_memory: redis_info["used_memory_peak_human"] || "N/A"
43
+ }
44
+ end
45
+
46
+ def render(tui, frame, area)
47
+ chunks = tui.layout_split(
48
+ area,
49
+ direction: :vertical,
50
+ constraints: [
51
+ tui.constraint_length(4), # Stats
52
+ tui.constraint_fill(1), # Graph
53
+ tui.constraint_length(4) # Redis
54
+ ]
55
+ )
56
+
57
+ render_stats_section(tui, frame, chunks[0])
58
+ render_chart_section(tui, frame, chunks[1])
59
+ render_redis_info_section(tui, frame, chunks[2])
60
+ end
61
+
62
+ def render_chart_section(tui, frame, area)
63
+ max_value = [@data[:chart][:deltas][:processed].max, @data[:chart][:deltas][:failed].max, 1].max
64
+ y_max = [max_value, 5].max
65
+
66
+ processed_data = @data[:chart][:deltas][:processed].each_with_index.map { |value, idx| [idx.to_f, value.to_f] }
67
+ failed_data = @data[:chart][:deltas][:failed].each_with_index.map { |value, idx| [idx.to_f, value.to_f] }
68
+
69
+ datasets = [
70
+ tui.dataset(
71
+ name: "",
72
+ data: processed_data,
73
+ style: tui.style(fg: :green),
74
+ marker: :dot,
75
+ graph_type: :line
76
+ ),
77
+ tui.dataset(
78
+ name: "",
79
+ data: failed_data,
80
+ style: tui.style(fg: :red),
81
+ marker: :dot,
82
+ graph_type: :line
83
+ )
84
+ ]
85
+
86
+ num_labels = 5
87
+ y_labels = (0...num_labels).map do |i|
88
+ value = ((y_max * i) / (num_labels - 1)).round
89
+ value.to_s
90
+ end
91
+
92
+ beacon_pulse = (Time.now.to_i % 2 == 0) ? "●" : " "
93
+
94
+ chart = tui.chart(
95
+ datasets: datasets,
96
+ x_axis: tui.axis(
97
+ bounds: [0.0, 49.0],
98
+ labels: [],
99
+ style: tui.style(fg: :white)
100
+ ),
101
+ y_axis: tui.axis(
102
+ bounds: [0.0, y_max.to_f],
103
+ labels: y_labels,
104
+ style: tui.style(fg: :white)
105
+ ),
106
+ block: tui.block(
107
+ title: "Dashboard #{beacon_pulse}",
108
+ borders: [:all]
109
+ )
110
+ )
111
+
112
+ frame.render_widget(chart, area)
113
+ end
114
+
115
+ def render_redis_info_section(tui, frame, area)
116
+ redis_info = @data[:redis_info]
117
+
118
+ uptime_value = (redis_info[:uptime_days] == "N/A") ? "N/A" : "#{redis_info[:uptime_days]} days"
119
+
120
+ keys = ["Version", "Uptime", "Connected Clients", "Memory Usage", "Peak Memory"]
121
+ values = [
122
+ redis_info[:version].to_s,
123
+ uptime_value,
124
+ redis_info[:connected_clients].to_s,
125
+ redis_info[:used_memory].to_s,
126
+ redis_info[:peak_memory].to_s
127
+ ]
128
+
129
+ # Format keys and values with spacing
130
+ keys_line = keys.map { |k| t(k).ljust(18) }.join(" ")
131
+ values_line = values.map { |v| v.ljust(18) }.join(" ")
132
+
133
+ frame.render_widget(
134
+ tui.paragraph(
135
+ text: [keys_line, values_line],
136
+ block: tui.block(title: "Redis Information", borders: [:all])
137
+ ),
138
+ area
139
+ )
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,131 @@
1
+ require_relative "base_tab"
2
+
3
+ module Sidekiq
4
+ class TUI
5
+ module Tabs
6
+ class Metrics < BaseTab
7
+ include Filtering
8
+
9
+ COLORS = %i[light_blue light_cyan light_yellow light_red light_green white gray]
10
+
11
+ def features
12
+ %i[filterable]
13
+ end
14
+
15
+ def on_filter_change
16
+ @data[:metrics_refresh] = nil
17
+ end
18
+
19
+ def regexp
20
+ filtering? ? Regexp.new(Regexp.escape(current_filter), Regexp::IGNORECASE) : nil
21
+ end
22
+
23
+ def refresh_data
24
+ refresh_data_for_stats
25
+
26
+ # only need to refresh every 60 seconds
27
+ if !@data[:metrics_refresh] || @data[:metrics_refresh] < Time.now
28
+ q = Sidekiq::Metrics::Query.new
29
+ query_result = q.top_jobs(class_filter: regexp, minutes: 60)
30
+ @data[:metrics] = query_result
31
+ @data[:metrics_refresh] = Time.now + 60
32
+ end
33
+ end
34
+
35
+ def render(tui, frame, area)
36
+ chunks = tui.layout_split(
37
+ area,
38
+ direction: :vertical,
39
+ constraints: [
40
+ tui.constraint_length(4), # Stats
41
+ tui.constraint_fill(1) # Chart
42
+ # TOOD Table
43
+ ]
44
+ )
45
+
46
+ render_stats_section(tui, frame, chunks[0])
47
+ render_metrics_chart(tui, frame, chunks[1])
48
+ end
49
+
50
+ # Run to generate metrics data:
51
+ # cd myapp && bundle install
52
+ # bundle exec rake seed_jobs
53
+ # bundle exec sidekiq
54
+ def render_metrics_chart(tui, frame, area)
55
+ y_max = 5
56
+ csize = COLORS.size
57
+ q = @data[:metrics]
58
+ job_results = q.job_results.sort_by { |(kls, jr)| jr.totals["s"] }.reverse.first(COLORS.size)
59
+ # visible_kls = job_results.first(5).map(&:first)
60
+ # chart_data = {
61
+ # series: job_results.map { |(kls, jr)| [kls, jr.dig("series", "s")] }.to_h,
62
+ # marks: query_result.marks.map { |m| [m.bucket, m.label] },
63
+ # starts_at: query_result.starts_at.iso8601,
64
+ # ends_at: query_result.ends_at.iso8601,
65
+ # visibleKls: visible_kls,
66
+ # yLabel: 'TotalExecutionTime',
67
+ # units: 'seconds',
68
+ # markLabel: '*',
69
+ # }
70
+
71
+ datasets = job_results.map.with_index do |(kls, data), idx|
72
+ # log kls, data, idx
73
+ hrdata = data.dig("series", "s")
74
+ tm = Time.now
75
+ tmi = tm.to_i
76
+ tm = Time.at(tmi - (tmi % 60)).utc
77
+ data = Array.new(60) { |idx| idx }.map do |bucket_idx|
78
+ jumpback = bucket_idx * 60
79
+ value = hrdata[(tm - jumpback).iso8601] || 0
80
+ y_max = value if value > y_max
81
+ # we have 60 data points, newest data should be
82
+ # at highest indexes so we have to rejigger the index
83
+ # here
84
+ [59 - bucket_idx, value]
85
+ end
86
+ # log data
87
+
88
+ # log(data)
89
+ tui.dataset(name: kls,
90
+ data: data,
91
+ style: tui.style(fg: COLORS[idx % csize]),
92
+ marker: :dot,
93
+ graph_type: :line)
94
+ end
95
+
96
+ num_labels = 5
97
+ y_labels = (0...num_labels).map do |i|
98
+ value = ((y_max * i) / (num_labels - 1)).round
99
+ value.to_s
100
+ end
101
+ xlabels = [
102
+ q.starts_at.iso8601[11..15],
103
+ q.ends_at.iso8601[11..15]
104
+ ]
105
+
106
+ # beacon_pulse = (Time.now.to_i % 2 == 0) ? "●" : " "
107
+
108
+ chart = tui.chart(
109
+ datasets: datasets,
110
+ x_axis: tui.axis(
111
+ bounds: [0.0, 60.0],
112
+ labels: xlabels,
113
+ style: tui.style(fg: :white)
114
+ ),
115
+ y_axis: tui.axis(
116
+ bounds: [0.0, y_max.to_f],
117
+ labels: y_labels,
118
+ style: tui.style(fg: :white)
119
+ ),
120
+ block: tui.block(
121
+ title: t(name),
122
+ borders: [:all]
123
+ )
124
+ )
125
+
126
+ frame.render_widget(chart, area)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end