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](screenshots/dashboard-3.png) +![Dashboard Overview - Light Theme](screenshots/dashboard-light.png) + +### Dashboard Overview (Dark Theme) + +![Dashboard Overview - Dark Theme](screenshots/dashboard-dark.png) ### 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 +
+
+
+ +

Job Activity

+ #{render_summary} +
+ #{render_time_select} +
+
+
+ #{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 + + #{render_grid_lines(max_value)} + #{render_axes} + #{render_x_labels} + #{render_y_labels(max_value)} + #{render_series_line(:failed, max_value)} + #{render_series_line(:completed, max_value)} + #{render_series_line(:created, max_value)} + #{render_series_points(:failed, max_value)} + #{render_series_points(:completed, max_value)} + #{render_series_points(:created, max_value)} + + 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

Solid Queue Monitor

- #{generate_auto_refresh_controls} +
+ #{generate_auto_refresh_controls} + #{generate_theme_toggle} +
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