Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions lib/solid_queue_tui/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"),
Expand Down
14 changes: 8 additions & 6 deletions lib/solid_queue_tui/data/jobs_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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|
Expand Down Expand Up @@ -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
Expand Down
176 changes: 144 additions & 32 deletions lib/solid_queue_tui/views/concerns/filterable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,98 +3,132 @@
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)
:refresh
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

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),
block: @tui.block(
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,
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/solid_queue_tui/views/finished_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down