Closed mperham closed 7 months ago
BTW, the project is at https://github.com/mperham/quick.
It was nice to meet you too and chat at RubyConf 2023. I'll take a look at your code and get back to you with answers.
Below is a general implementation example that follows the Glimmer application conventions (using Glimmer::LibUI::Application
for the main app class) and divides the GUI into multiple View components (using Glimmer::LibUI::CustomControl
mixin).
Here is an Animated Gif:
Note that the example implementation currently generates its own fake data. Also, the graph can be improved upon by adding background line patterns, and by not rendering any points that are not visible to speed up performance.
You can divide the GUI the same way you think about it. This is how I divided the GUI:
AdminUI
: main app with multiple tabs; only the dashboard is implemented.Dashboard
: provides dashboard
componentGlobalStats
: provides global_stat
componentGlobalStat
: provides global_stat
componentStatusBar
(footer): provides status_bar
componentTypically, we use label
s to display information unless there is a need to add a font/color, in which case, we could use text
under the area
control to do so. Also, desktop apps usually have simpler platform conforming user interfaces, so over-customization of look and feel is avoided in favor of a more native and simple look and feel.
Ruby code:
require 'glimmer-dsl-libui'
# Models
class Job
STATUSES = %i[processed failed busy enqueued retried scheduled dead]
attr_accessor :id, :status, :time
end
class JobManager
attr_accessor :jobs, :polling_interval
attr_reader :version, :redis_url, :time, :docs_url, :locale
def initialize
@initialize_time = Time.now
@jobs = []
@polling_interval = 1
@version = '7.2.0'
@redis_url = 'redis://localhost:6379/0'
@time = Time.now.utc
@docs_url = 'https://github.com/mperham/sidekiq/wiki'
@locale = 'en'
end
def job_count_for_status(status)
jobs.select {|job| job.status == status.to_s.to_sym}.size
end
def report_points
points = []
current_jobs = jobs.dup
start_time = @initialize_time
end_time = Time.now
time_length = (end_time - start_time).to_i
time_length.times do |n|
job_found = current_jobs.detect do |job|
job_delay = job.time - start_time
job_delay.between?(n, n + 1)
end
x = n*15
y = job_found ? 5 : 195
points << [x, y]
end
translate_points(points)
points
end
def translate_points(points)
max_job_count_before_translation = ((800/15).to_i + 1)
x_translation = [(points.size - max_job_count_before_translation)*15, 0].max
if x_translation > 0
points.each do |point|
point[0] = point[0] - x_translation
end
end
end
end
# Views (Components)
class GlobalStat
include Glimmer::LibUI::CustomControl
option :job_manager
option :job_status
body {
vertical_box {
label {
text <= [job_manager, :jobs,
on_read: ->(jobs_array) {
job_manager.job_count_for_status(job_status).to_s
}
]
}
label(job_status.to_s.capitalize)
}
}
end
class GlobalStats
include Glimmer::LibUI::CustomControl
option :job_manager
body {
horizontal_box {
stretchy false
Job::STATUSES.each do |job_status|
global_stat(job_manager: job_manager, job_status: job_status)
end
}
}
end
class DashboardGraph
include Glimmer::LibUI::CustomControl
option :job_manager
after_body do
polling_interval = job_manager.polling_interval
time_remaining = job_manager.polling_interval
timer_interval = 1 # 1 second
Glimmer::LibUI.timer(timer_interval) do
if polling_interval != job_manager.polling_interval
if job_manager.polling_interval < polling_interval
time_remaining = job_manager.polling_interval
else
time_remaining += job_manager.polling_interval - polling_interval
end
polling_interval = job_manager.polling_interval
end
time_remaining -= timer_interval
if time_remaining == 0
body_root.queue_redraw_all
time_remaining = job_manager.polling_interval
end
end
end
body {
area {
stretchy false
rectangle(0, 0, 800, 200) {
fill 255, 255, 255
}
on_draw do
last_point = nil
job_manager.report_points.each do |point|
circle(point.first, point.last, 3) {
fill 0, 128, 0
}
if last_point
line(last_point.first, last_point.last, point.first, point.last) {
stroke 0, 128, 0, thickness: 2
}
end
last_point = point
end
end
}
}
end
class Dashboard
include Glimmer::LibUI::CustomControl
option :job_manager
body {
vertical_box {
global_stats(job_manager: job_manager)
horizontal_box {
label('Dashboard') {
stretchy false
}
# filler
label
vertical_box {
horizontal_box {
label('Polling interval:') {
stretchy false
}
label {
text <= [job_manager, :polling_interval,
on_read: ->(val) { "#{val} sec" }
]
}
}
slider(1, 10) {
value <=> [job_manager, :polling_interval]
}
}
}
dashboard_graph(job_manager: job_manager)
status_bar(job_manager: job_manager)
}
}
end
class StatusBar
include Glimmer::LibUI::CustomControl
option :job_manager
before_body do
@text_font_family = (OS.mac? ? 'Helvetica' : 'Arial')
@text_font_size = (OS.mac? ? 14 : 11)
@text_font = {family: @text_font_family, size: @text_font_size}
@text_color = :grey
@background_color = :black
end
body {
area {
rectangle(0, 0, 800, 30) {
fill @background_color
}
text(20, 5, 100) {
string("Sidekiq v#{job_manager.version}") {
font @text_font
color @text_color
}
}
text(120, 5, 160) {
string(job_manager.redis_url) {
font @text_font
color @text_color
}
}
text(280, 5, 100) {
string(job_manager.time.strftime('%T UTC')) {
font @text_font
color @text_color
}
}
text(380, 5, 100) {
string("docs #{job_manager.locale}") {
font @text_font
color :red
}
on_mouse_up do
system "open #{job_manager.docs_url}"
end
}
}
}
end
# Main Application View
class AdminUI
include Glimmer::LibUI::Application
before_body do
@job_manager = JobManager.new
end
after_body do
generate_jobs
end
body {
window('Sidekiq UI', 800, 450) {
vertical_box {
tab {
tab_item('Dashboard') {
dashboard(job_manager: @job_manager)
}
tab_item('Busy') {
}
tab_item('Queues') {
}
tab_item('Retries') {
}
tab_item('Scheduled') {
}
tab_item('Dead') {
}
tab_item('Metrics') {
}
}
}
}
}
def generate_jobs
Glimmer::LibUI.timer(1) do
if rand(5) == 0
job = Job.new
job.time = Time.now
job.id = job.time.to_f*10000000
job.status = Job::STATUSES.sample
@job_manager.jobs << job
end
end
end
end
AdminUI.launch
You can save in a file called admin_ui.rb
and run via glimmer admin_ui.rb
Please make sure to place each class in a separate file when adapting this code to your application.
If you encounter issues or think of other questions, I'd be happy to help you further with this by submitting Pull Requests to your repo. I just thought I'd give you a chance to learn from the code example I gave you first.
P.S. LibUI
is still alpha, so it suffers from layout issues when including multiple area
controls in the user interface, and it does not support customization of colors/fonts on label
controls. It also has slow performance when rendering canvas graphics, albeit that is currently being worked on by replacing FFI with Native C Extensions for canvas graphic rendering. These issues/limitations should get addressed in the future.
In the meantime, if there is a need for something more robust and mature, Glimmer DSL for SWT supports a mature GUI toolkit called Eclipse SWT with the ability to package apps as native EXE/MSI/APP/PKG/DMG/DEB/RPM files. The only caveat is it requires JRuby instead of CRuby, but that is hidden from the user if the app is packaged natively. But, of course, CRuby offers the benefit of instant startup time (vs about 2.5 seconds with JRuby on Mac M2) as it does NOT need to load Java like JRuby does. That is why I maintain Glimmer DSL for LibUI with the hope that LibUI would catch up with SWT eventually as these two toolkits support native controls on all platforms. Glimmer DSL for WX also supports native controls and is the newest Glimmer GUI library, but the wxWidgets toolkit does not get installed quickly via binaries like LibUI, yet requires native compilation, which takes a while. I should be expanding Glimmer DSL for WX support further in the future too.
Whew, Andy, that's really generous. Thank you for the time and your expertise!
One other thing I forgot to mention is you need to install the latest glimmer-dsl-libui (or newer when new versions come out) to make the code example that I shared work. I noticed you had a very old version in your branch due to not specifying a version.
To install manually:
gem install glimmer-dsl-libui -v0.11.3
When using Bundler, ensure Gemfile
has the following:
gem 'glimmer-dsl-libui', '0.11.3'
Or this if you want to upgrade when related patch versions are available:
gem 'glimmer-dsl-libui', '~> 0.11.3'
There is an outstanding bug in 0.11.3 that I will fix soon in relation to content data-binding (a newly added feature), but it should work with the code I shared as it is not impacted by the bug.
Hey Andy, nice meeting you and chatting at length the other night. I'm attempting a spike of the Sidekiq Web UI written in glimmer-dsl-libui but I'm struggling to find documentation on the various components and their properties available.
Here's the Web UI:
There's four main vertical pieces:
I'm not tied to the web layout, it may not be appropriate for a desktop app, so "outside of the box" design ideas are welcome.