From 9a35c9294b41027fb69cfcc286febc873bf556f0 Mon Sep 17 00:00:00 2001 From: Shivareddy-Aluri Date: Sat, 28 Feb 2026 00:27:00 +0530 Subject: [PATCH] Add date-range filter for finished jobs Allow filtering completed/finished jobs by a date preset. - Add date preset UI to Filterable: introduce TEXT_FIELDS, DATE_FIELD and DATE_PRESETS, manage active preset, render date field, and handle keyboard interactions (j/k to cycle, tab/back_tab to change fields). Update filter application/clearing to include date preset state and include the preset label in filter titles and bindings. - Wire the date range into data queries: add since parameter to Data::JobsQuery.fetch and .count and apply finished_at >= since in fetch_finished and count_finished. - Update the application to pass the since value when loading/appending finished jobs and update the help text for the filter action. - Enable the date preset field in the Finished view by overriding filter_fields. This change makes it possible to view completed jobs limited to common recent time ranges (last hour, 6h, 24h, 7d, 30d, or all time). --- lib/solid_queue_tui/application.rb | 10 +- lib/solid_queue_tui/data/jobs_query.rb | 14 +- .../views/concerns/filterable.rb | 176 ++++++++++++++---- lib/solid_queue_tui/views/finished_view.rb | 6 + 4 files changed, 164 insertions(+), 42 deletions(-) diff --git a/lib/solid_queue_tui/application.rb b/lib/solid_queue_tui/application.rb index f1ff8c1..4be5366 100644 --- a/lib/solid_queue_tui/application.rb +++ b/lib/solid_queue_tui/application.rb @@ -311,8 +311,9 @@ def refresh_data! current_view.update(jobs: jobs) when VIEW_FINISHED f = current_view.filters - current_view.total_count = Data::JobsQuery.count(status: "completed", filter: f[:class_name], queue: f[:queue]) - jobs = Data::JobsQuery.fetch(status: "completed", filter: f[:class_name], queue: f[:queue], limit: SolidQueueTui.page_size, offset: 0) + since = current_view.date_range_start + current_view.total_count = Data::JobsQuery.count(status: "completed", filter: f[:class_name], queue: f[:queue], since: since) + jobs = Data::JobsQuery.fetch(status: "completed", filter: f[:class_name], queue: f[:queue], since: since, limit: SolidQueueTui.page_size, offset: 0) current_view.update(jobs: jobs) when VIEW_RECURRING tasks = Data::RecurringTasksQuery.fetch @@ -355,7 +356,8 @@ def load_more_data! view.append(jobs: more) when VIEW_FINISHED f = view.filters - more = Data::JobsQuery.fetch(status: "completed", filter: f[:class_name], queue: f[:queue], limit: SolidQueueTui.page_size, offset: offset) + since = view.date_range_start + more = Data::JobsQuery.fetch(status: "completed", filter: f[:class_name], queue: f[:queue], since: since, limit: SolidQueueTui.page_size, offset: offset) view.append(jobs: more) end rescue => e @@ -491,7 +493,7 @@ def render_help_overlay(frame, area) empty_line, help_section("Actions"), help_line("r", "Refresh data"), - help_line("/", "Filter by class name"), + help_line("/", "Filter (class, queue, date range)"), help_line("c", "Clear active filter"), help_line("R", "Retry failed job (in Failed view)"), help_line("D", "Discard failed job (in Failed view)"), diff --git a/lib/solid_queue_tui/data/jobs_query.rb b/lib/solid_queue_tui/data/jobs_query.rb index c112115..9745712 100644 --- a/lib/solid_queue_tui/data/jobs_query.rb +++ b/lib/solid_queue_tui/data/jobs_query.rb @@ -11,26 +11,26 @@ class JobsQuery keyword_init: true ) - def self.fetch(status:, filter: nil, queue: nil, limit: 100, offset: 0) + def self.fetch(status:, filter: nil, queue: nil, since: nil, limit: 100, offset: 0) case status when "pending" then fetch_pending(queue: queue, filter: filter, limit: limit, offset: offset) when "claimed" then fetch_claimed(filter: filter, queue: queue, limit: limit, offset: offset) when "blocked" then fetch_blocked(filter: filter, queue: queue, limit: limit, offset: offset) when "scheduled" then fetch_scheduled(filter: filter, queue: queue, limit: limit, offset: offset) - when "completed" then fetch_finished(filter: filter, queue: queue, limit: limit, offset: offset) + when "completed" then fetch_finished(filter: filter, queue: queue, since: since, limit: limit, offset: offset) else [] end rescue => e [] end - def self.count(status:, filter: nil, queue: nil) + def self.count(status:, filter: nil, queue: nil, since: nil) case status when "pending" then count_pending(queue: queue, filter: filter) when "claimed" then count_scope(SolidQueue::ClaimedExecution.joins(:job), filter: filter, queue: queue) when "blocked" then count_scope(SolidQueue::BlockedExecution.joins(:job), filter: filter, queue: queue) when "scheduled" then count_scope(SolidQueue::ScheduledExecution.joins(:job), filter: filter, queue: queue) - when "completed" then count_finished(filter: filter, queue: queue) + when "completed" then count_finished(filter: filter, queue: queue, since: since) else 0 end rescue => e @@ -133,10 +133,11 @@ def self.fetch_scheduled(filter: nil, queue: nil, limit: 100, offset: 0) end end - def self.fetch_finished(filter: nil, queue: nil, limit: 100, offset: 0) + def self.fetch_finished(filter: nil, queue: nil, since: nil, limit: 100, offset: 0) scope = SolidQueue::Job.finished scope = scope.where(queue_name: queue) if queue scope = scope.where("class_name LIKE ?", "%#{filter}%") if filter.present? + scope = scope.where("finished_at >= ?", since) if since scope = scope.order(finished_at: :desc).offset(offset).limit(limit) scope.map do |job| @@ -165,10 +166,11 @@ def self.fetch_finished(filter: nil, queue: nil, limit: 100, offset: 0) scope.count end - private_class_method def self.count_finished(filter: nil, queue: nil) + private_class_method def self.count_finished(filter: nil, queue: nil, since: nil) scope = SolidQueue::Job.finished scope = scope.where(queue_name: queue) if queue scope = scope.where("class_name LIKE ?", "%#{filter}%") if filter.present? + scope = scope.where("finished_at >= ?", since) if since scope.count end end diff --git a/lib/solid_queue_tui/views/concerns/filterable.rb b/lib/solid_queue_tui/views/concerns/filterable.rb index 9a0defe..41fdbf0 100644 --- a/lib/solid_queue_tui/views/concerns/filterable.rb +++ b/lib/solid_queue_tui/views/concerns/filterable.rb @@ -3,25 +3,59 @@ module SolidQueueTui module Views module Filterable - FILTER_FIELDS = [ - { key: :class_name, label: "Class" }, - { key: :queue, label: "Queue" } + TEXT_FIELDS = [ + { key: :class_name, label: "Class", type: :text }, + { key: :queue, label: "Queue", type: :text } ].freeze + DATE_FIELD = { key: :date, label: "Date", type: :preset }.freeze + + DATE_PRESETS = [ + { label: "Last 1 hour", seconds: 3_600 }, + { label: "Last 6 hours", seconds: 21_600 }, + { label: "Last 24 hours", seconds: 86_400 }, + { label: "Last 7 days", seconds: 604_800 }, + { label: "Last 30 days", seconds: 2_592_000 }, + { label: "All time", seconds: nil } + ].freeze + + ALL_TIME_INDEX = DATE_PRESETS.size - 1 + + # Override in views that need the date preset field + def filter_fields + TEXT_FIELDS + end + def init_filter @filters = {} @filter_mode = false @filter_inputs = {} @active_field = 0 + @date_preset_index = ALL_TIME_INDEX + @active_date_preset = nil end def filters = @filters def filter_mode? = @filter_mode + def date_range_start + return nil unless @active_date_preset + + preset = DATE_PRESETS[@active_date_preset] + return nil unless preset && preset[:seconds] + + Time.now.utc - preset[:seconds] + end + def handle_filter_input(event) + fields = filter_fields case event in { type: :key, code: "enter" } @filters = @filter_inputs.reject { |_, v| v.nil? || v.empty? } + if has_date_field? + preset = DATE_PRESETS[@date_preset_index] + @active_date_preset = preset[:seconds] ? @date_preset_index : nil + end @filter_mode = false @selected_row = 0 @table_state.select(0) @@ -29,24 +63,20 @@ def handle_filter_input(event) in { type: :key, code: "esc" } @filter_mode = false @filter_inputs = @filters.dup + @date_preset_index = @active_date_preset || ALL_TIME_INDEX nil in { type: :key, code: "tab" } - @active_field = (@active_field + 1) % FILTER_FIELDS.size + @active_field = (@active_field + 1) % fields.size nil in { type: :key, code: "back_tab" } - @active_field = (@active_field - 1) % FILTER_FIELDS.size - nil - in { type: :key, code: "backspace" } - field_key = FILTER_FIELDS[@active_field][:key] - current = @filter_inputs[field_key] || "" - @filter_inputs[field_key] = current[0...-1] - nil - in { type: :key, code: /\A.\z/ => char } - field_key = FILTER_FIELDS[@active_field][:key] - @filter_inputs[field_key] = (@filter_inputs[field_key] || "") + char + @active_field = (@active_field - 1) % fields.size nil else - nil + if date_field_active? + handle_date_field_input(event) + else + handle_text_field_input(event) + end end end @@ -54,38 +84,43 @@ def enter_filter_mode @filter_mode = true @filter_inputs = @filters.dup @active_field = 0 + @date_preset_index = @active_date_preset || ALL_TIME_INDEX end def clear_filter + had_filters = !@filters.empty? || !!@active_date_preset @filters = {} @filter_inputs = {} - :refresh + @active_date_preset = nil + had_filters ? :refresh : nil end def render_filter_input(frame, area) spans = [] - FILTER_FIELDS.each_with_index do |field, idx| + filter_fields.each_with_index do |field, idx| active = idx == @active_field - value = @filter_inputs[field[:key]] || "" spans << @tui.text_span( content: " #{field[:label]}: ", style: @tui.style(fg: active ? :yellow : :dark_gray, modifiers: active ? [:bold] : []) ) - if active - spans << @tui.text_span(content: value + "\u2588", style: @tui.style(fg: :white)) + if field[:type] == :preset + render_date_field_span(spans, active) else - spans << @tui.text_span( - content: value.empty? ? "\u2014" : value, - style: @tui.style(fg: :dark_gray) - ) + render_text_field_span(spans, field, active) end spans << @tui.text_span(content: " ", style: @tui.style(fg: :white)) end + hint = if date_field_active? + " Tab: next field \u2502 j/k: cycle \u2502 Enter: apply \u2502 Esc: cancel " + else + " Tab: next field \u2502 Enter: apply \u2502 Esc: cancel " + end + frame.render_widget( @tui.paragraph( text: @tui.text_line(spans: spans), @@ -93,8 +128,7 @@ def render_filter_input(frame, area) title: " Filters ", title_style: @tui.style(fg: :yellow), titles: [ - { content: " Tab: next field \u2502 Enter: apply \u2502 Esc: cancel ", - position: :top, alignment: :right } + { content: hint, position: :top, alignment: :right } ], borders: [:all], border_type: :rounded, @@ -106,26 +140,104 @@ def render_filter_input(frame, area) end def filter_title(base_title) - return base_title if @filters.empty? - - parts = FILTER_FIELDS.filter_map do |field| + parts = filter_fields.filter_map do |field| + next if field[:type] == :preset value = @filters[field[:key]] "#{field[:label].downcase}: #{value}" if value && !value.empty? end - "#{base_title} (#{parts.join(', ')})" + if @active_date_preset + parts << DATE_PRESETS[@active_date_preset][:label].downcase + end + + parts.empty? ? base_title : "#{base_title} (#{parts.join(', ')})" end def clear_filter_binding - @filters.empty? ? nil : { key: "c", action: "Clear Filter" } + has_filters = !@filters.empty? || !!@active_date_preset + has_filters ? { key: "c", action: "Clear Filter" } : nil end def filter_bindings - [ + bindings = [ { key: "Tab", action: "Next Field" }, { key: "Enter", action: "Apply" }, { key: "Esc", action: "Cancel" } ] + bindings.unshift({ key: "j/k", action: "Cycle" }) if date_field_active? + bindings + end + + private + + def has_date_field? + filter_fields.any? { |f| f[:type] == :preset } + end + + def date_field_active? + @filter_mode && filter_fields[@active_field]&.[](:type) == :preset + end + + def handle_date_field_input(event) + case event + in { type: :key, code: "j" } | { type: :key, code: "down" } + @date_preset_index = (@date_preset_index + 1) % DATE_PRESETS.size + nil + in { type: :key, code: "k" } | { type: :key, code: "up" } + @date_preset_index = (@date_preset_index - 1) % DATE_PRESETS.size + nil + else + nil + end + end + + def handle_text_field_input(event) + fields = filter_fields + case event + in { type: :key, code: "backspace" } + field_key = fields[@active_field][:key] + current = @filter_inputs[field_key] || "" + @filter_inputs[field_key] = current[0...-1] + nil + in { type: :key, code: /\A.\z/ => char } + field_key = fields[@active_field][:key] + @filter_inputs[field_key] = (@filter_inputs[field_key] || "") + char + nil + else + nil + end + end + + def render_text_field_span(spans, field, active) + value = @filter_inputs[field[:key]] || "" + if active + spans << @tui.text_span(content: value + "\u2588", style: @tui.style(fg: :white)) + else + spans << @tui.text_span( + content: value.empty? ? "\u2014" : value, + style: @tui.style(fg: :dark_gray) + ) + end + end + + def render_date_field_span(spans, active) + label = DATE_PRESETS[@date_preset_index][:label] + if active + spans << @tui.text_span( + content: label, + style: @tui.style(fg: :yellow, modifiers: [:bold]) + ) + spans << @tui.text_span( + content: " \u25B2\u25BC", + style: @tui.style(fg: :dark_gray) + ) + else + display = @date_preset_index == ALL_TIME_INDEX ? "\u2014" : DATE_PRESETS[@date_preset_index][:label] + spans << @tui.text_span( + content: display, + style: @tui.style(fg: :dark_gray) + ) + end end end end diff --git a/lib/solid_queue_tui/views/finished_view.rb b/lib/solid_queue_tui/views/finished_view.rb index 67a0c02..c78916a 100644 --- a/lib/solid_queue_tui/views/finished_view.rb +++ b/lib/solid_queue_tui/views/finished_view.rb @@ -7,12 +7,18 @@ class FinishedView include Paginatable include FormattingHelpers + FINISHED_FILTER_FIELDS = (TEXT_FIELDS + [DATE_FIELD]).freeze + def initialize(tui) @tui = tui init_pagination init_filter end + def filter_fields + FINISHED_FILTER_FIELDS + end + def update(jobs:) update_items(jobs) end