diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08757a5..f06d23f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,30 @@
# Changelog
+## [0.6.0] - 2026-01-20
+
+### Added
+
+- Job Activity Chart on dashboard showing jobs created, completed, and failed over time
+ - Pure SVG line chart with no external dependencies
+ - 9 configurable time ranges: 15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 1w
+ - Collapsible chart section with summary totals visible when collapsed
+ - Interactive tooltips on hover
+ - Smart empty state handling (hides empty series, shows message when no data)
+- Dark theme support with toggle button
+ - Toggle between light and dark themes
+ - Respects system preference (`prefers-color-scheme: dark`)
+ - Persists user preference in localStorage
+ - True black (#000000) background for OLED displays
+- Wider layout (95% width, max 1800px) for better screen utilization
+- Navigation active state highlighting current page
+- New `ChartDataService` for aggregating job metrics into time buckets
+- New `ChartPresenter` for rendering SVG charts
+
+### Improved
+
+- Updated all UI components to use CSS variables for consistent theming
+- Enhanced visual hierarchy with improved color contrast in both themes
+
## [0.5.0] - 2026-01-16
### Added
diff --git a/Gemfile.lock b/Gemfile.lock
index f30ba65..b34a7a0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
- solid_queue_monitor (0.5.0)
+ solid_queue_monitor (0.6.0)
rails (>= 7.0)
solid_queue (>= 0.1.0)
diff --git a/README.md b/README.md
index c67a1ad..6510783 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
## Features
- **Dashboard Overview**: Get a quick snapshot of your queue's health with statistics on all job types
+- **Job Activity Chart**: Visual line chart showing jobs created, completed, and failed over time with 9 time range options (15m to 1 week)
+- **Dark Theme**: Toggle between light and dark themes with system preference detection and localStorage persistence
- **Ready Jobs**: View jobs that are ready to be executed
- **In Progress Jobs**: Monitor jobs currently being processed by workers
- **Scheduled Jobs**: See upcoming jobs scheduled for future execution with ability to execute immediately or reject permanently
@@ -33,9 +35,13 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
## Screenshots
-### Dashboard Overview
+### Dashboard Overview (Light Theme)
-
+
+
+### Dashboard Overview (Dark Theme)
+
+
### Failed Jobs
@@ -46,7 +52,7 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
Add this line to your application's Gemfile:
```ruby
-gem 'solid_queue_monitor', '~> 0.4.0'
+gem 'solid_queue_monitor', '~> 0.6.0'
```
Then execute:
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..454cdf1
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,48 @@
+# Roadmap
+
+This document tracks planned features for solid_queue_monitor, comparing with other solutions like `solid-queue-dashboard` and `mission_control-jobs`.
+
+## High Priority - Core Functionality Gaps
+
+| Feature | solid-queue-dashboard | mission_control-jobs | Impact | Status |
+|---------|:---------------------:|:--------------------:|--------|:------:|
+| Auto-refresh | ✓ | - | High - Real-time monitoring essential for ops | ✅ Done (v0.4.0) |
+| Charts/Visualizations | ✓ | - | High - Visual trends are compelling | ⬚ Planned |
+| Pause/Unpause Queues | - | ✓ | High - Critical for production incident response | ✅ Done (v0.5.0) |
+| Worker Monitoring | - | ✓ | High - See which workers are processing what | ⬚ Planned |
+| Dead Process Detection | ✓ | - | High - Identify stuck/zombie processes | ⬚ Planned |
+| Execution History | ✓ | - | Medium - Job audit trail | ⬚ Planned |
+| Failure Rate Tracking | ✓ | - | Medium - Trends over time | ⬚ Planned |
+
+## Medium Priority - Power Features
+
+| Feature | Description | Status |
+|---------|-------------|:------:|
+| Sensitive Argument Masking | Filter passwords/tokens from job arguments display | ⬚ Planned |
+| Backtrace Cleaner | Remove framework noise from error backtraces | ⬚ Planned |
+| Manual Job Triggering | Enqueue a job directly from the dashboard | ⬚ Planned |
+| Cancel Running Jobs | Stop long-running jobs | ⬚ Planned |
+| Search/Full-text Search | Better search across all job data | ⬚ Planned |
+| Sorting Options | Sort by various columns | ⬚ Planned |
+| Job Details Page | Dedicated page for single job with full context | ⬚ Planned |
+
+## Lower Priority - Enterprise Features
+
+| Feature | Description | Status |
+|---------|-------------|:------:|
+| Multi-app Support | Manage multiple apps from one dashboard | ⬚ Planned |
+| Multi-database Support | Connect to different Solid Queue databases | ⬚ Planned |
+| Console Helpers | Ruby API for scripting job operations | ⬚ Planned |
+| Bulk Operation Throttling | Delay between bulk ops to prevent DB overload | ⬚ Planned |
+| Export Jobs (CSV/JSON) | Download job data for analysis | ⬚ Planned |
+| Webhooks/Notifications | Alert on failures via Slack/email | ⬚ Planned |
+| API Endpoints (JSON) | Return JSON for custom integrations | ⬚ Planned |
+| Dark Mode Toggle | User preference for theme | ⬚ Planned |
+
+---
+
+## Legend
+
+- ✅ Done - Feature implemented
+- 🚧 In Progress - Currently being worked on
+- ⬚ Planned - Not yet started
diff --git a/app/controllers/solid_queue_monitor/overview_controller.rb b/app/controllers/solid_queue_monitor/overview_controller.rb
index 282811b..5d88b48 100644
--- a/app/controllers/solid_queue_monitor/overview_controller.rb
+++ b/app/controllers/solid_queue_monitor/overview_controller.rb
@@ -4,6 +4,7 @@ module SolidQueueMonitor
class OverviewController < BaseController
def index
@stats = SolidQueueMonitor::StatsCalculator.calculate
+ @chart_data = SolidQueueMonitor::ChartDataService.new(time_range: time_range_param).calculate
recent_jobs_query = SolidQueue::Job.order(created_at: :desc).limit(100)
@recent_jobs = paginate(filter_jobs(recent_jobs_query))
@@ -13,10 +14,20 @@ def index
render_page('Overview', generate_overview_content)
end
+ def chart_data
+ chart_data = SolidQueueMonitor::ChartDataService.new(time_range: time_range_param).calculate
+ render json: chart_data
+ end
+
private
+ def time_range_param
+ params[:time_range] || ChartDataService::DEFAULT_TIME_RANGE
+ end
+
def generate_overview_content
SolidQueueMonitor::StatsPresenter.new(@stats).render +
+ SolidQueueMonitor::ChartPresenter.new(@chart_data).render +
SolidQueueMonitor::JobsPresenter.new(@recent_jobs[:records],
current_page: @recent_jobs[:current_page],
total_pages: @recent_jobs[:total_pages],
diff --git a/app/services/solid_queue_monitor/chart_data_service.rb b/app/services/solid_queue_monitor/chart_data_service.rb
new file mode 100644
index 0000000..0315a49
--- /dev/null
+++ b/app/services/solid_queue_monitor/chart_data_service.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+module SolidQueueMonitor
+ class ChartDataService
+ TIME_RANGES = {
+ '15m' => { duration: 15.minutes, buckets: 15, label_format: '%H:%M', label: 'Last 15 minutes' },
+ '30m' => { duration: 30.minutes, buckets: 15, label_format: '%H:%M', label: 'Last 30 minutes' },
+ '1h' => { duration: 1.hour, buckets: 12, label_format: '%H:%M', label: 'Last 1 hour' },
+ '3h' => { duration: 3.hours, buckets: 18, label_format: '%H:%M', label: 'Last 3 hours' },
+ '6h' => { duration: 6.hours, buckets: 24, label_format: '%H:%M', label: 'Last 6 hours' },
+ '12h' => { duration: 12.hours, buckets: 24, label_format: '%H:%M', label: 'Last 12 hours' },
+ '1d' => { duration: 1.day, buckets: 24, label_format: '%H:%M', label: 'Last 24 hours' },
+ '3d' => { duration: 3.days, buckets: 36, label_format: '%m/%d %H:%M', label: 'Last 3 days' },
+ '1w' => { duration: 7.days, buckets: 28, label_format: '%m/%d', label: 'Last 7 days' }
+ }.freeze
+
+ DEFAULT_TIME_RANGE = '1d'
+
+ def initialize(time_range: DEFAULT_TIME_RANGE)
+ @time_range = TIME_RANGES.key?(time_range) ? time_range : DEFAULT_TIME_RANGE
+ @config = TIME_RANGES[@time_range]
+ end
+
+ def calculate
+ end_time = Time.current
+ start_time = end_time - @config[:duration]
+ bucket_duration = @config[:duration] / @config[:buckets]
+
+ buckets = build_buckets(start_time, bucket_duration)
+
+ created_counts = fetch_created_counts(start_time, end_time)
+ completed_counts = fetch_completed_counts(start_time, end_time)
+ failed_counts = fetch_failed_counts(start_time, end_time)
+
+ created_data = assign_to_buckets(created_counts, buckets, bucket_duration)
+ completed_data = assign_to_buckets(completed_counts, buckets, bucket_duration)
+ failed_data = assign_to_buckets(failed_counts, buckets, bucket_duration)
+
+ {
+ labels: buckets.map { |b| b[:label] }, # rubocop:disable Rails/Pluck
+ created: created_data,
+ completed: completed_data,
+ failed: failed_data,
+ totals: {
+ created: created_data.sum,
+ completed: completed_data.sum,
+ failed: failed_data.sum
+ },
+ time_range: @time_range,
+ time_range_label: @config[:label],
+ available_ranges: TIME_RANGES.transform_values { |v| v[:label] }
+ }
+ end
+
+ private
+
+ def build_buckets(start_time, bucket_duration)
+ @config[:buckets].times.map do |i|
+ bucket_start = start_time + (i * bucket_duration)
+ {
+ start: bucket_start,
+ end: bucket_start + bucket_duration,
+ label: bucket_start.strftime(@config[:label_format])
+ }
+ end
+ end
+
+ def fetch_created_counts(start_time, end_time)
+ SolidQueue::Job
+ .where(created_at: start_time..end_time)
+ .pluck(:created_at)
+ end
+
+ def fetch_completed_counts(start_time, end_time)
+ SolidQueue::Job
+ .where(finished_at: start_time..end_time)
+ .where.not(finished_at: nil)
+ .pluck(:finished_at)
+ end
+
+ def fetch_failed_counts(start_time, end_time)
+ SolidQueue::FailedExecution
+ .where(created_at: start_time..end_time)
+ .pluck(:created_at)
+ end
+
+ def assign_to_buckets(timestamps, buckets, _bucket_duration)
+ counts = Array.new(buckets.size, 0)
+
+ timestamps.each do |timestamp|
+ bucket_index = buckets.find_index do |bucket|
+ timestamp >= bucket[:start] && timestamp < bucket[:end]
+ end
+ counts[bucket_index] += 1 if bucket_index
+ end
+
+ counts
+ end
+ end
+end
diff --git a/app/services/solid_queue_monitor/chart_presenter.rb b/app/services/solid_queue_monitor/chart_presenter.rb
new file mode 100644
index 0000000..bd19557
--- /dev/null
+++ b/app/services/solid_queue_monitor/chart_presenter.rb
@@ -0,0 +1,239 @@
+# frozen_string_literal: true
+
+module SolidQueueMonitor
+ class ChartPresenter
+ CHART_WIDTH = 1200
+ CHART_HEIGHT = 280
+ PADDING = { top: 40, right: 30, bottom: 60, left: 60 }.freeze
+ COLORS = {
+ created: '#3b82f6', # Blue
+ completed: '#10b981', # Green
+ failed: '#ef4444' # Red
+ }.freeze
+
+ def initialize(chart_data)
+ @data = chart_data
+ @plot_width = CHART_WIDTH - PADDING[:left] - PADDING[:right]
+ @plot_height = CHART_HEIGHT - PADDING[:top] - PADDING[:bottom]
+ end
+
+ def render
+ <<-HTML
+
+
+
+
+ #{render_svg}
+
+ #{render_legend}
+
+
+ #{render_tooltip}
+ HTML
+ end
+
+ private
+
+ def render_summary
+ totals = @data[:totals] || { created: 0, completed: 0, failed: 0 }
+ <<-HTML
+
+ #{totals[:created]} created
+ ·
+ #{totals[:completed]} completed
+ ·
+ #{totals[:failed]} failed
+
+ HTML
+ end
+
+ def render_time_select
+ options = @data[:available_ranges].map do |key, label|
+ selected = key == @data[:time_range] ? 'selected' : ''
+ ""
+ end.join
+
+ <<-HTML
+
+
+
+ HTML
+ end
+
+ def render_svg
+ return render_empty_state if all_series_empty?
+
+ max_value = calculate_max_value
+ max_value = 10 if max_value.zero?
+
+ <<-SVG
+
+ SVG
+ end
+
+ def all_series_empty?
+ %i[created completed failed].all? { |series| series_empty?(series) }
+ end
+
+ def series_empty?(series)
+ @data[series].nil? || @data[series].all?(&:zero?)
+ end
+
+ def render_empty_state
+ <<-HTML
+
+ No job activity in this time range
+
+ HTML
+ end
+
+ def render_series_line(series, max_value)
+ return '' if series_empty?(series)
+
+ render_line(series, max_value)
+ end
+
+ def render_series_points(series, max_value)
+ return '' if series_empty?(series)
+
+ render_data_points(series, max_value)
+ end
+
+ def calculate_max_value
+ all_values = @data[:created] + @data[:completed] + @data[:failed]
+ max = all_values.max || 0
+ # Round up to nice number
+ return 10 if max <= 10
+
+ magnitude = 10**Math.log10(max).floor
+ ((max.to_f / magnitude).ceil * magnitude)
+ end
+
+ def render_grid_lines(_max_value)
+ lines = []
+ 5.times do |i|
+ y = PADDING[:top] + (@plot_height * i / 4.0)
+ lines << ""
+ end
+ lines.join("\n")
+ end
+
+ def render_axes
+ <<-SVG
+
+
+ SVG
+ end
+
+ def render_x_labels
+ labels = @data[:labels]
+ return '' if labels.empty?
+
+ # Show fewer labels if too many
+ step = labels.size > 12 ? (labels.size / 6.0).ceil : 1
+
+ label_elements = labels.each_with_index.map do |label, i|
+ next unless (i % step).zero? || i == labels.size - 1
+
+ x = PADDING[:left] + (@plot_width * i / (labels.size - 1).to_f)
+ "#{label}"
+ end.compact
+
+ label_elements.join("\n")
+ end
+
+ def render_y_labels(max_value)
+ labels = []
+ 5.times do |i|
+ value = (max_value * (4 - i) / 4.0).round
+ y = PADDING[:top] + (@plot_height * i / 4.0)
+ labels << "#{value}"
+ end
+ labels.join("\n")
+ end
+
+ def render_line(series, max_value)
+ points = calculate_points(series, max_value)
+ return '' if points.empty?
+
+ points_str = points.map { |p| "#{p[:x]},#{p[:y]}" }.join(' ')
+
+ ""
+ end
+
+ def render_data_points(series, max_value)
+ points = calculate_points(series, max_value)
+ values = @data[series]
+
+ points.each_with_index.map do |point, i|
+ <<-SVG
+
+ SVG
+ end.join("\n")
+ end
+
+ def calculate_points(series, max_value)
+ values = @data[series]
+ return [] if values.blank?
+
+ values.each_with_index.map do |value, i|
+ x = PADDING[:left] + (@plot_width * i / (values.size - 1).to_f)
+ y = CHART_HEIGHT - PADDING[:bottom] - (@plot_height * value / max_value.to_f)
+ { x: x.round(2), y: y.round(2) }
+ end
+ end
+
+ def render_legend
+ <<-HTML
+
+
+
+ Created
+
+
+
+ Completed
+
+
+
+ Failed
+
+
+ HTML
+ end
+
+ def render_tooltip
+ <<-HTML
+
+ HTML
+ end
+ end
+end
diff --git a/app/services/solid_queue_monitor/html_generator.rb b/app/services/solid_queue_monitor/html_generator.rb
index a6b5218..617c63f 100644
--- a/app/services/solid_queue_monitor/html_generator.rb
+++ b/app/services/solid_queue_monitor/html_generator.rb
@@ -51,6 +51,7 @@ def generate_body
#{generate_footer}
#{generate_auto_refresh_script}
+ #{generate_chart_script}
HTML
end
@@ -87,20 +88,32 @@ def render_message
end
def generate_header
+ nav_items = [
+ { path: root_path, label: 'Overview', match: 'Overview' },
+ { path: ready_jobs_path, label: 'Ready Jobs', match: 'Ready Jobs' },
+ { path: in_progress_jobs_path, label: 'In Progress Jobs', match: 'In Progress' },
+ { path: scheduled_jobs_path, label: 'Scheduled Jobs', match: 'Scheduled Jobs' },
+ { path: recurring_jobs_path, label: 'Recurring Jobs', match: 'Recurring Jobs' },
+ { path: failed_jobs_path, label: 'Failed Jobs', match: 'Failed Jobs' },
+ { path: queues_path, label: 'Queues', match: 'Queues' }
+ ]
+
+ nav_links = nav_items.map do |item|
+ active_class = @title&.include?(item[:match]) ? 'active' : ''
+ "#{item[:label]}"
+ end.join("\n ")
+
<<-HTML
HTML
@@ -135,6 +148,27 @@ def generate_auto_refresh_controls
HTML
end
+ def generate_theme_toggle
+ <<-HTML
+
+ HTML
+ end
+
def generate_auto_refresh_script
return '' unless SolidQueueMonitor.auto_refresh_enabled
@@ -212,6 +246,132 @@ def auto_refresh_init
JS
end
+ def generate_chart_script
+ <<-HTML
+
+ HTML
+ end
+
+ def theme_toggle_javascript
+ <<-JS
+ (function() {
+ var body = document.body;
+ var themeBtn = document.getElementById('theme-toggle-btn');
+ var storageKey = 'sqm_dark_theme';
+
+ // Check for saved preference or system preference
+ function getPreferredTheme() {
+ var saved = localStorage.getItem(storageKey);
+ if (saved !== null) {
+ return saved === 'true';
+ }
+ // Check system preference
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ }
+
+ function setTheme(isDark) {
+ if (isDark) {
+ body.classList.add('dark-theme');
+ } else {
+ body.classList.remove('dark-theme');
+ }
+ localStorage.setItem(storageKey, isDark ? 'true' : 'false');
+ }
+
+ // Initialize theme
+ setTheme(getPreferredTheme());
+
+ // Toggle on button click
+ if (themeBtn) {
+ themeBtn.addEventListener('click', function() {
+ var isDark = body.classList.contains('dark-theme');
+ setTheme(!isDark);
+ });
+ }
+
+ // Listen for system preference changes
+ if (window.matchMedia) {
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
+ // Only auto-switch if user hasn't manually set a preference
+ if (localStorage.getItem(storageKey) === null) {
+ setTheme(e.matches);
+ }
+ });
+ }
+ })();
+ JS
+ end
+
+ def chart_tooltip_javascript
+ <<-JS
+ (function() {
+ // Chart collapse/expand functionality
+ var chartSection = document.getElementById('chart-section');
+ var toggleBtn = document.getElementById('chart-toggle-btn');
+
+ if (chartSection && toggleBtn) {
+ var isCollapsed = localStorage.getItem('sqm_chart_collapsed') === 'true';
+
+ if (isCollapsed) {
+ chartSection.classList.add('collapsed');
+ }
+
+ toggleBtn.addEventListener('click', function() {
+ chartSection.classList.toggle('collapsed');
+ var collapsed = chartSection.classList.contains('collapsed');
+ localStorage.setItem('sqm_chart_collapsed', collapsed ? 'true' : 'false');
+ });
+ }
+
+ // Chart tooltip functionality
+ var tooltip = document.getElementById('chart-tooltip');
+ if (!tooltip) return;
+
+ var dataPoints = document.querySelectorAll('.data-point');
+ var seriesNames = { created: 'Created', completed: 'Completed', failed: 'Failed' };
+
+ dataPoints.forEach(function(point) {
+ point.addEventListener('mouseenter', function(e) {
+ var series = this.getAttribute('data-series');
+ var label = this.getAttribute('data-label');
+ var value = this.getAttribute('data-value');
+
+ tooltip.querySelector('.tooltip-label').textContent = label;
+ tooltip.querySelector('.tooltip-value').textContent = seriesNames[series] + ': ' + value;
+ tooltip.style.display = 'block';
+ positionTooltip(e);
+ });
+
+ point.addEventListener('mousemove', function(e) {
+ positionTooltip(e);
+ });
+
+ point.addEventListener('mouseleave', function() {
+ tooltip.style.display = 'none';
+ });
+ });
+
+ function positionTooltip(e) {
+ var x = e.clientX + 10;
+ var y = e.clientY - 30;
+
+ if (x + tooltip.offsetWidth > window.innerWidth) {
+ x = e.clientX - tooltip.offsetWidth - 10;
+ }
+ if (y < 0) {
+ y = e.clientY + 10;
+ }
+
+ tooltip.style.left = x + 'px';
+ tooltip.style.top = y + 'px';
+ }
+ })();
+ JS
+ end
+
def default_url_options
{ only_path: true }
end
diff --git a/app/services/solid_queue_monitor/stylesheet_generator.rb b/app/services/solid_queue_monitor/stylesheet_generator.rb
index 8869656..cee447f 100644
--- a/app/services/solid_queue_monitor/stylesheet_generator.rb
+++ b/app/services/solid_queue_monitor/stylesheet_generator.rb
@@ -8,9 +8,35 @@ def generate
--primary-color: #3b82f6;
--success-color: #10b981;
--error-color: #ef4444;
+ --warning-color: #f59e0b;
--text-color: #1f2937;
+ --text-muted: #6b7280;
--border-color: #e5e7eb;
--background-color: #f9fafb;
+ --card-background: #ffffff;
+ --card-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ --input-background: #ffffff;
+ --input-border: #d1d5db;
+ --hover-background: #f3f4f6;
+ --code-background: #f5f5f5;
+ }
+
+ /* Dark theme */
+ .solid_queue_monitor.dark-theme {
+ --primary-color: #60a5fa;
+ --success-color: #34d399;
+ --error-color: #f87171;
+ --warning-color: #fbbf24;
+ --text-color: #f9fafb;
+ --text-muted: #9ca3af;
+ --border-color: #2d2d2d;
+ --background-color: #000000;
+ --card-background: #121212;
+ --card-shadow: 0 1px 3px rgba(0,0,0,0.5);
+ --input-background: #1e1e1e;
+ --input-border: #3d3d3d;
+ --hover-background: #1e1e1e;
+ --code-background: #1e1e1e;
}
.solid_queue_monitor * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -23,7 +49,8 @@ def generate
}
.solid_queue_monitor .container {
- max-width: 1200px;
+ width: 95%;
+ max-width: 1800px;
margin: 0 auto;
padding: 2rem;
}
@@ -52,8 +79,8 @@ def generate
color: var(--text-color);
padding: 0.5rem 1rem;
border-radius: 0.375rem;
- background: white;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ background: var(--card-background);
+ box-shadow: var(--card-shadow);
transition: all 0.2s;
}
@@ -89,15 +116,15 @@ def generate
.solid_queue_monitor .stat-card {
flex: 1 1 0;
min-width: 150px;
- background: white;
+ background: var(--card-background);
padding: 1.5rem 1rem;
border-radius: 0.5rem;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ box-shadow: var(--card-shadow);
text-align: center;
}
.solid_queue_monitor .stat-card h3 {
- color: #6b7280;
+ color: var(--text-muted);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -137,11 +164,12 @@ def generate
}
.solid_queue_monitor th {
- background: var(--background-color);
+ background: var(--hover-background);
font-weight: 500;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
+ color: var(--text-muted);
}
.solid_queue_monitor .status-badge {
@@ -242,7 +270,7 @@ def generate
.solid_queue_monitor footer {
text-align: center;
padding: 2rem 0;
- color: #6b7280;
+ color: var(--text-muted);
}
.solid_queue_monitor .pagination {
@@ -289,7 +317,7 @@ def generate
}
.solid_queue_monitor .pagination-link {
- background: white;
+ background: var(--card-background);
color: var(--text-color);
border: 1px solid var(--border-color);
}
@@ -320,7 +348,7 @@ def generate
max-height: 100px;
overflow-y: auto;
padding: 8px;
- background: #f5f5f5;
+ background: var(--code-background);
border-radius: 4px;
font-size: 0.9em;
}
@@ -328,7 +356,7 @@ def generate
.solid_queue_monitor .args-single-line {
display: inline-block;
padding: 4px 8px;
- background: #f5f5f5;
+ background: var(--code-background);
border-radius: 4px;
font-size: 0.9em;
}
@@ -399,10 +427,10 @@ def generate
}
.solid_queue_monitor .filter-form-container {
- background: white;
+ background: var(--card-background);
padding: 1rem;
border-radius: 0.5rem;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ box-shadow: var(--card-shadow);
flex: 3;
}
@@ -411,9 +439,9 @@ def generate
flex-direction: row;
gap: 0.75rem;
padding: 1rem;
- background: white;
+ background: var(--card-background);
border-radius: 0.5rem;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ box-shadow: var(--card-shadow);
flex: 2;
align-items: center;
justify-content: center;
@@ -453,16 +481,18 @@ def generate
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
- color: #4b5563;
+ color: var(--text-muted);
}
.solid_queue_monitor .filter-group input,
.solid_queue_monitor .filter-group select {
width: 100%;
padding: 0.5rem;
- border: 1px solid #d1d5db;
+ border: 1px solid var(--input-border);
border-radius: 0.375rem;
font-size: 0.875rem;
+ background: var(--input-background);
+ color: var(--text-color);
}
.solid_queue_monitor .filter-actions {
@@ -486,9 +516,9 @@ def generate
}
.solid_queue_monitor .reset-button {
- background: #f3f4f6;
- color: #4b5563;
- border: 1px solid #d1d5db;
+ background: var(--hover-background);
+ color: var(--text-muted);
+ border: 1px solid var(--border-color);
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
@@ -498,7 +528,7 @@ def generate
}
.solid_queue_monitor .reset-button:hover {
- background: #e5e7eb;
+ background: var(--border-color);
}
.solid_queue_monitor .action-button {
@@ -560,7 +590,7 @@ def generate
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
- background: #f3f4f6;
+ background: var(--code-background);
padding: 0.5rem;
border-radius: 0.25rem;
margin-top: 0.5rem;
@@ -572,12 +602,12 @@ def generate
.solid_queue_monitor summary {
cursor: pointer;
- color: #6b7280;
+ color: var(--text-muted);
font-size: 0.75rem;
}
.solid_queue_monitor summary:hover {
- color: #4b5563;
+ color: var(--text-color);
}
.solid_queue_monitor .job-checkbox,
@@ -590,10 +620,10 @@ def generate
display: flex;
gap: 0.75rem;
margin: 1rem 0;
- background: white;
+ background: var(--card-background);
padding: 0.75rem;
border-radius: 0.5rem;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ box-shadow: var(--card-shadow);
}
.solid_queue_monitor .bulk-actions-bar .action-button {
@@ -625,11 +655,11 @@ def generate
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
- background: white;
+ background: var(--card-background);
border-radius: 2rem;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ box-shadow: var(--card-shadow);
font-size: 0.75rem;
- color: #6b7280;
+ color: var(--text-muted);
cursor: default;
}
@@ -682,7 +712,7 @@ def generate
width: 6px;
height: 6px;
border-radius: 50%;
- background: #d1d5db;
+ background: var(--border-color);
flex-shrink: 0;
}
@@ -726,7 +756,7 @@ def generate
left: 0;
right: 0;
bottom: 0;
- background-color: #d1d5db;
+ background-color: var(--border-color);
transition: 0.2s;
border-radius: 18px;
}
@@ -738,7 +768,7 @@ def generate
width: 14px;
left: 2px;
bottom: 2px;
- background-color: white;
+ background-color: var(--card-background);
transition: 0.2s;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
@@ -761,7 +791,7 @@ def generate
padding: 0.25rem;
border-radius: 0.25rem;
cursor: pointer;
- color: #9ca3af;
+ color: var(--text-muted);
transition: all 0.2s;
}
@@ -786,6 +816,344 @@ def generate
display: none;
}
}
+
+ /* Navigation active state */
+ .solid_queue_monitor .nav-link.active {
+ background: var(--primary-color);
+ color: white;
+ border-left: 3px solid #1d4ed8;
+ }
+
+ /* Chart styles */
+ .solid_queue_monitor .chart-section {
+ background: var(--card-background);
+ border-radius: 0.5rem;
+ box-shadow: var(--card-shadow);
+ padding: 1rem 1.5rem;
+ margin-bottom: 2rem;
+ }
+
+ .solid_queue_monitor .chart-section.collapsed {
+ padding-bottom: 1rem;
+ }
+
+ .solid_queue_monitor .chart-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ }
+
+ .solid_queue_monitor .chart-header-left {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+
+ .solid_queue_monitor .chart-header h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-color);
+ margin: 0;
+ }
+
+ .solid_queue_monitor .chart-toggle-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ background: var(--hover-background);
+ border: 1px solid var(--border-color);
+ border-radius: 0.375rem;
+ cursor: pointer;
+ color: var(--text-muted);
+ transition: all 0.2s;
+ }
+
+ .solid_queue_monitor .chart-toggle-btn:hover {
+ background: var(--border-color);
+ color: var(--text-color);
+ }
+
+ .solid_queue_monitor .chart-toggle-icon {
+ transition: transform 0.2s;
+ }
+
+ .solid_queue_monitor .chart-section.collapsed .chart-toggle-icon {
+ transform: rotate(-90deg);
+ }
+
+ .solid_queue_monitor .chart-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin-left: 0.5rem;
+ padding-left: 0.75rem;
+ border-left: 1px solid var(--border-color);
+ }
+
+ .solid_queue_monitor .summary-item {
+ white-space: nowrap;
+ }
+
+ .solid_queue_monitor .summary-created {
+ color: #3b82f6;
+ }
+
+ .solid_queue_monitor .summary-completed {
+ color: #10b981;
+ }
+
+ .solid_queue_monitor .summary-failed {
+ color: #ef4444;
+ }
+
+ .solid_queue_monitor .summary-separator {
+ color: var(--border-color);
+ }
+
+ .solid_queue_monitor .chart-time-select-wrapper {
+ position: relative;
+ }
+
+ .solid_queue_monitor .chart-time-select {
+ appearance: none;
+ padding: 0.5rem 2rem 0.5rem 0.75rem;
+ font-size: 0.8rem;
+ color: var(--text-color);
+ background: var(--input-background);
+ border: 1px solid var(--border-color);
+ border-radius: 0.375rem;
+ cursor: pointer;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.5rem center;
+ background-size: 14px;
+ min-width: 140px;
+ }
+
+ .solid_queue_monitor .chart-time-select:hover {
+ border-color: var(--text-muted);
+ }
+
+ .solid_queue_monitor .chart-time-select:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
+ }
+
+ .solid_queue_monitor .chart-collapsible {
+ overflow: hidden;
+ transition: max-height 0.3s ease-out, opacity 0.2s ease-out, margin-top 0.3s ease-out;
+ max-height: 500px;
+ opacity: 1;
+ margin-top: 1rem;
+ }
+
+ .solid_queue_monitor .chart-section.collapsed .chart-collapsible {
+ max-height: 0;
+ opacity: 0;
+ margin-top: 0;
+ }
+
+ .solid_queue_monitor .chart-container {
+ width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
+
+ .solid_queue_monitor .chart-container svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ }
+
+ .solid_queue_monitor .job-activity-chart {
+ width: 100%;
+ height: auto;
+ min-height: 250px;
+ }
+
+ .solid_queue_monitor .grid-line {
+ stroke: var(--border-color);
+ stroke-width: 1;
+ stroke-dasharray: 4 4;
+ }
+
+ .solid_queue_monitor .axis-line {
+ stroke: var(--border-color);
+ stroke-width: 1;
+ }
+
+ .solid_queue_monitor .axis-label {
+ font-size: 11px;
+ fill: var(--text-muted);
+ }
+
+ .solid_queue_monitor .x-label {
+ text-anchor: middle;
+ }
+
+ .solid_queue_monitor .y-label {
+ text-anchor: end;
+ }
+
+ .solid_queue_monitor .chart-line {
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+
+ .solid_queue_monitor .data-point {
+ cursor: pointer;
+ transition: r 0.2s;
+ }
+
+ .solid_queue_monitor .data-point:hover {
+ r: 6;
+ }
+
+ .solid_queue_monitor .chart-legend {
+ display: flex;
+ justify-content: center;
+ gap: 1.5rem;
+ margin-top: 1rem;
+ flex-wrap: wrap;
+ }
+
+ .solid_queue_monitor .legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.875rem;
+ color: var(--text-muted);
+ }
+
+ .solid_queue_monitor .legend-color {
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+ }
+
+ .solid_queue_monitor .chart-tooltip {
+ position: fixed;
+ background: #1f2937;
+ color: white;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ pointer-events: none;
+ z-index: 1000;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+ }
+
+ .solid_queue_monitor .tooltip-label {
+ font-weight: 500;
+ margin-bottom: 0.25rem;
+ }
+
+ .solid_queue_monitor .tooltip-value {
+ color: #d1d5db;
+ }
+
+ .solid_queue_monitor .chart-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 200px;
+ color: var(--text-muted);
+ font-size: 0.875rem;
+ }
+
+ @media (max-width: 768px) {
+ .solid_queue_monitor .chart-section {
+ padding: 1rem;
+ }
+
+ .solid_queue_monitor .chart-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .solid_queue_monitor .chart-header-left {
+ width: 100%;
+ flex-wrap: wrap;
+ }
+
+ .solid_queue_monitor .chart-summary {
+ margin-left: 0;
+ padding-left: 0;
+ border-left: none;
+ margin-top: 0.5rem;
+ width: 100%;
+ }
+
+ .solid_queue_monitor .chart-time-select {
+ width: 100%;
+ }
+
+ .solid_queue_monitor .job-activity-chart {
+ min-height: 200px;
+ }
+
+ .solid_queue_monitor .chart-legend {
+ gap: 1rem;
+ }
+ }
+
+ /* Theme toggle button */
+ .solid_queue_monitor .theme-toggle-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ background: var(--card-background);
+ border: 1px solid var(--border-color);
+ border-radius: 50%;
+ cursor: pointer;
+ color: var(--text-muted);
+ transition: all 0.2s;
+ box-shadow: var(--card-shadow);
+ }
+
+ .solid_queue_monitor .theme-toggle-btn:hover {
+ color: var(--text-color);
+ border-color: var(--text-muted);
+ }
+
+ .solid_queue_monitor .theme-toggle-btn svg {
+ width: 18px;
+ height: 18px;
+ }
+
+ /* Hide moon icon in light mode, show sun icon */
+ .solid_queue_monitor .theme-icon-moon {
+ display: none;
+ }
+
+ .solid_queue_monitor .theme-icon-sun {
+ display: block;
+ }
+
+ /* In dark mode, show moon icon, hide sun icon */
+ .solid_queue_monitor.dark-theme .theme-icon-moon {
+ display: block;
+ }
+
+ .solid_queue_monitor.dark-theme .theme-icon-sun {
+ display: none;
+ }
+
+ .solid_queue_monitor .header-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ }
CSS
end
end
diff --git a/config/routes.rb b/config/routes.rb
index 9ad5a1f..1ce4bcd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -6,6 +6,8 @@
root to: 'overview#index'
+ get 'chart_data', to: 'overview#chart_data', as: :chart_data
+
resources :ready_jobs, only: [:index]
resources :scheduled_jobs, only: [:index]
resources :recurring_jobs, only: [:index]
diff --git a/lib/solid_queue_monitor/version.rb b/lib/solid_queue_monitor/version.rb
index 89b8755..75f0214 100644
--- a/lib/solid_queue_monitor/version.rb
+++ b/lib/solid_queue_monitor/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module SolidQueueMonitor
- VERSION = '0.5.0'
+ VERSION = '0.6.0'
end
diff --git a/screenshots/dashboard-dark.png b/screenshots/dashboard-dark.png
new file mode 100644
index 0000000..04986ae
Binary files /dev/null and b/screenshots/dashboard-dark.png differ
diff --git a/screenshots/dashboard-light.png b/screenshots/dashboard-light.png
new file mode 100644
index 0000000..76f05c7
Binary files /dev/null and b/screenshots/dashboard-light.png differ
diff --git a/spec/services/solid_queue_monitor/chart_data_service_spec.rb b/spec/services/solid_queue_monitor/chart_data_service_spec.rb
new file mode 100644
index 0000000..18e95a6
--- /dev/null
+++ b/spec/services/solid_queue_monitor/chart_data_service_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+# rubocop:disable RSpec/VerifiedDoubles
+RSpec.describe SolidQueueMonitor::ChartDataService do
+ describe '#calculate' do
+ let(:service) { described_class.new(time_range: time_range) }
+ let(:time_range) { '1d' }
+
+ before do
+ # Mock the created_at query chain
+ created_relation = double('created_relation')
+ allow(created_relation).to receive(:pluck).with(:created_at).and_return([])
+ allow(SolidQueue::Job).to receive(:where).with(created_at: anything).and_return(created_relation)
+
+ # Mock the finished_at query chain (where.where.not.pluck)
+ completed_relation = double('completed_relation')
+ completed_not_relation = double('completed_not_relation')
+ allow(completed_relation).to receive(:where).and_return(completed_not_relation)
+ allow(completed_not_relation).to receive(:not).and_return(completed_not_relation)
+ allow(completed_not_relation).to receive(:pluck).with(:finished_at).and_return([])
+ allow(SolidQueue::Job).to receive(:where).with(finished_at: anything).and_return(completed_relation)
+
+ # Mock the failed executions query
+ failed_relation = double('failed_relation')
+ allow(failed_relation).to receive(:pluck).with(:created_at).and_return([])
+ allow(SolidQueue::FailedExecution).to receive(:where).with(created_at: anything).and_return(failed_relation)
+ end
+
+ it 'returns chart data structure' do
+ result = service.calculate
+
+ expect(result).to include(
+ :labels,
+ :created,
+ :completed,
+ :failed,
+ :totals,
+ :time_range,
+ :time_range_label,
+ :available_ranges
+ )
+ end
+
+ it 'returns correct number of buckets for 1d range' do
+ result = service.calculate
+
+ expect(result[:labels].size).to eq(24)
+ expect(result[:created].size).to eq(24)
+ expect(result[:completed].size).to eq(24)
+ expect(result[:failed].size).to eq(24)
+ end
+
+ it 'returns the current time range' do
+ result = service.calculate
+
+ expect(result[:time_range]).to eq('1d')
+ end
+
+ it 'returns all available time ranges with labels' do
+ result = service.calculate
+
+ expect(result[:available_ranges].keys).to eq(%w[15m 30m 1h 3h 6h 12h 1d 3d 1w])
+ end
+
+ context 'with 1h time range' do
+ let(:time_range) { '1h' }
+
+ it 'returns 12 buckets' do
+ result = service.calculate
+
+ expect(result[:labels].size).to eq(12)
+ end
+ end
+
+ context 'with 1w time range' do
+ let(:time_range) { '1w' }
+
+ it 'returns 28 buckets' do
+ result = service.calculate
+
+ expect(result[:labels].size).to eq(28)
+ end
+ end
+
+ context 'with invalid time range' do
+ let(:time_range) { 'invalid' }
+
+ it 'defaults to 1d' do
+ result = service.calculate
+
+ expect(result[:time_range]).to eq('1d')
+ expect(result[:labels].size).to eq(24)
+ end
+ end
+
+ context 'with job data' do # rubocop:disable RSpec/MultipleMemoizedHelpers
+ let(:now) { Time.current }
+ let(:created_timestamps) { [now - 30.minutes, now - 45.minutes] }
+ let(:completed_timestamps) { [now - 20.minutes] }
+ let(:failed_timestamps) { [now - 10.minutes, now - 15.minutes] }
+
+ before do
+ # Override mocks with actual data
+ created_relation = double('created_relation')
+ allow(created_relation).to receive(:pluck).with(:created_at).and_return(created_timestamps)
+ allow(SolidQueue::Job).to receive(:where).with(created_at: anything).and_return(created_relation)
+
+ completed_relation = double('completed_relation')
+ completed_not_relation = double('completed_not_relation')
+ allow(completed_relation).to receive(:where).and_return(completed_not_relation)
+ allow(completed_not_relation).to receive(:not).and_return(completed_not_relation)
+ allow(completed_not_relation).to receive(:pluck).with(:finished_at).and_return(completed_timestamps)
+ allow(SolidQueue::Job).to receive(:where).with(finished_at: anything).and_return(completed_relation)
+
+ failed_relation = double('failed_relation')
+ allow(failed_relation).to receive(:pluck).with(:created_at).and_return(failed_timestamps)
+ allow(SolidQueue::FailedExecution).to receive(:where).with(created_at: anything).and_return(failed_relation)
+ end
+
+ it 'aggregates job counts into buckets' do
+ result = service.calculate
+
+ total_created = result[:created].sum
+ total_completed = result[:completed].sum
+ total_failed = result[:failed].sum
+
+ expect(total_created).to eq(2)
+ expect(total_completed).to eq(1)
+ expect(total_failed).to eq(2)
+ end
+ end
+ end
+
+ describe 'TIME_RANGES' do
+ it 'defines all expected time ranges' do
+ expect(described_class::TIME_RANGES.keys).to eq(%w[15m 30m 1h 3h 6h 12h 1d 3d 1w])
+ end
+
+ it 'has duration, buckets, label_format, and label for each range' do
+ described_class::TIME_RANGES.each do |key, config|
+ expect(config).to include(:duration, :buckets, :label_format, :label),
+ "Expected #{key} to have duration, buckets, label_format, and label"
+ end
+ end
+ end
+
+ describe 'DEFAULT_TIME_RANGE' do
+ it 'is 1d' do
+ expect(described_class::DEFAULT_TIME_RANGE).to eq('1d')
+ end
+ end
+end
+# rubocop:enable RSpec/VerifiedDoubles