diff --git a/app/controllers/mission_control/jobs/dashboard_controller.rb b/app/controllers/mission_control/jobs/dashboard_controller.rb new file mode 100644 index 00000000..238bd9c2 --- /dev/null +++ b/app/controllers/mission_control/jobs/dashboard_controller.rb @@ -0,0 +1,4 @@ +class MissionControl::Jobs::DashboardController < MissionControl::Jobs::ApplicationController + def index + end +end diff --git a/app/controllers/mission_control/jobs/internal_api/dashboard_controller.rb b/app/controllers/mission_control/jobs/internal_api/dashboard_controller.rb new file mode 100644 index 00000000..ad626003 --- /dev/null +++ b/app/controllers/mission_control/jobs/internal_api/dashboard_controller.rb @@ -0,0 +1,35 @@ +class MissionControl::Jobs::InternalApi::DashboardController < MissionControl::Jobs.base_controller_class.constantize + include ActionView::Helpers::NumberHelper + + def index + render json: { + uptime: { + label: Time.now.strftime("%H:%M:%S"), + pending: queue_job.pendings.where.not(id: failed_execution.select(:job_id)).size, + failed: failed_execution.where("created_at >= ?", time_to_consult.seconds.ago).size, + finished: queue_job.finisheds.where("finished_at >= ?", time_to_consult.seconds.ago).size, + }, + total: { + failed: number_with_delimiter(ActiveJob.jobs.failed.count), + pending: number_with_delimiter(ActiveJob.jobs.pending.count), + scheduled: number_with_delimiter(ActiveJob.jobs.scheduled.count), + in_progress: number_with_delimiter(ActiveJob.jobs.in_progress.count), + finished: number_with_delimiter(ActiveJob.jobs.finished.count) + } + }, + status: :ok + end + + private + def time_to_consult + params[:uptime].to_i || 5 + end + + def failed_execution + MissionControl::SolidQueueFailedExecution + end + + def queue_job + MissionControl::SolidQueueJob + end +end diff --git a/app/controllers/mission_control/jobs/internal_api/navigation_controller.rb b/app/controllers/mission_control/jobs/internal_api/navigation_controller.rb new file mode 100644 index 00000000..42b7e19f --- /dev/null +++ b/app/controllers/mission_control/jobs/internal_api/navigation_controller.rb @@ -0,0 +1,7 @@ +class MissionControl::Jobs::InternalApi::NavigationController < MissionControl::Jobs::ApplicationController + def index + render partial: "layouts/mission_control/jobs/navigation_update", locals: { + section: params[:section].to_sym + } + end +end diff --git a/app/helpers/mission_control/jobs/navigation_helper.rb b/app/helpers/mission_control/jobs/navigation_helper.rb index db2a48ed..65772e6e 100644 --- a/app/helpers/mission_control/jobs/navigation_helper.rb +++ b/app/helpers/mission_control/jobs/navigation_helper.rb @@ -2,7 +2,10 @@ module MissionControl::Jobs::NavigationHelper attr_reader :page_title, :current_section def navigation_sections - { queues: [ "Queues", application_queues_path(@application) ] }.tap do |sections| + { dashboard: [ "Dashboard", application_dashboard_index_path(@application) ] }.tap do |sections| + sections[:queues] = [ "Queues", application_queues_path(@application) ] + sections[:queues] = [ "Queues", application_queues_path(@application) ] + supported_job_statuses.without(:pending).each do |status| sections[navigation_section_for_status(status)] = [ "#{status.to_s.titleize} jobs (#{jobs_count_with_status(status)})", application_jobs_path(@application, status) ] end diff --git a/app/models/mission_control/solid_queue_failed_execution.rb b/app/models/mission_control/solid_queue_failed_execution.rb new file mode 100644 index 00000000..039ae778 --- /dev/null +++ b/app/models/mission_control/solid_queue_failed_execution.rb @@ -0,0 +1,3 @@ +class MissionControl::SolidQueueFailedExecution < MissionControl::SolidQueueRecord + self.table_name = 'solid_queue_failed_executions' +end diff --git a/app/models/mission_control/solid_queue_job.rb b/app/models/mission_control/solid_queue_job.rb new file mode 100644 index 00000000..4c9f92a4 --- /dev/null +++ b/app/models/mission_control/solid_queue_job.rb @@ -0,0 +1,6 @@ +class MissionControl::SolidQueueJob < MissionControl::SolidQueueRecord + self.table_name = 'solid_queue_jobs' + + scope :pendings, -> { where(finished_at: nil) } + scope :finisheds, -> { where.not(finished_at: nil) } +end diff --git a/app/models/mission_control/solid_queue_record.rb b/app/models/mission_control/solid_queue_record.rb new file mode 100644 index 00000000..1da46599 --- /dev/null +++ b/app/models/mission_control/solid_queue_record.rb @@ -0,0 +1,7 @@ +class MissionControl::SolidQueueRecord < ApplicationRecord + self.abstract_class = true + + if !ActiveRecord::Base.connection.data_source_exists?('solid_queue_jobs') + connects_to database: { writing: :queue, reading: :queue } + end +end diff --git a/app/views/layouts/mission_control/jobs/_navigation.html.erb b/app/views/layouts/mission_control/jobs/_navigation.html.erb index 67347fa9..2cc2df81 100644 --- a/app/views/layouts/mission_control/jobs/_navigation.html.erb +++ b/app/views/layouts/mission_control/jobs/_navigation.html.erb @@ -1,4 +1,4 @@ -
+ + + diff --git a/app/views/layouts/mission_control/jobs/_navigation_update.html.erb b/app/views/layouts/mission_control/jobs/_navigation_update.html.erb new file mode 100644 index 00000000..d9e8a74d --- /dev/null +++ b/app/views/layouts/mission_control/jobs/_navigation_update.html.erb @@ -0,0 +1,7 @@ + diff --git a/app/views/layouts/mission_control/jobs/application.html.erb b/app/views/layouts/mission_control/jobs/application.html.erb index 64c8dc5c..80dee801 100644 --- a/app/views/layouts/mission_control/jobs/application.html.erb +++ b/app/views/layouts/mission_control/jobs/application.html.erb @@ -8,6 +8,8 @@ + + <%= stylesheet_link_tag "mission_control/jobs/application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags "application", importmap: MissionControl::Jobs.importmap %> diff --git a/app/views/mission_control/jobs/dashboard/index.html.erb b/app/views/mission_control/jobs/dashboard/index.html.erb new file mode 100644 index 00000000..70101665 --- /dev/null +++ b/app/views/mission_control/jobs/dashboard/index.html.erb @@ -0,0 +1,182 @@ +<% navigation(title: "Dashboard", section: :dashboard) %> + +
+
+
+
Pending
+ -- +
+
+
+
+
Scheduled
+ -- +
+
+
+
+
In Progress
+ -- +
+
+
+
+
Finished
+ -- +
+
+
+
+
Failed
+ -- +
+
+
+ +
+
+ General Overview +
+
+
+ +
+
+
+
+ +
+ +
+ + diff --git a/config/routes.rb b/config/routes.rb index 96ba0bb5..cbdf8cda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ MissionControl::Jobs::Engine.routes.draw do resources :applications, only: [] do + resources :dashboard, only: [ :index ] resources :queues, only: [ :index, :show ] do scope module: :queues do resource :pause, only: [ :create, :destroy ] @@ -17,6 +18,11 @@ end end + namespace :internal_api do + resources :dashboard, only: [ :index ] + resources :navigation, only: [ :index ] + end + resources :jobs, only: :index, path: ":status/jobs" resources :workers, only: [ :index, :show ] diff --git a/test/controllers/internal_api/dashboard_controller_test.rb b/test/controllers/internal_api/dashboard_controller_test.rb new file mode 100644 index 00000000..794ba485 --- /dev/null +++ b/test/controllers/internal_api/dashboard_controller_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +class MissionControl::Jobs::InternalApi::DashboardControllerTest < ActionDispatch::IntegrationTest + setup do + @base_url = mission_control_jobs.application_internal_api_dashboard_index_url(@application) + MissionControl::SolidQueueJob.destroy_all + MissionControl::SolidQueueFailedExecution.destroy_all + end + + test "index returns valid JSON structure" do + get @base_url + assert_response :ok + + response_data = JSON.parse(response.body) + assert response_data.key?("uptime") + assert response_data.key?("total") + + %w[label pending failed finished].each do |key| + assert response_data["uptime"].key?(key) + end + + %w[failed pending scheduled in_progress finished].each do |key| + assert response_data["total"].key?(key) + end + end + + test "index correctly formats numbers with delimiters" do + ActiveJob::Base.queue_adapter = :test + + 1234.times { FailingJob.perform_later } + 1678.times { DummyJob.perform_later } + + await_perform_all_enqueued_jobs + + get @base_url + assert_response :ok + + response_data = JSON.parse(response.body) + + assert_equal "1,234", response_data["total"]["failed"] + assert_equal "1,678", response_data["total"]["finished"] + end + + test "index calculates uptime data correctly" do + job = MissionControl::SolidQueueJob.create!(queue_name: 'default', class_name: 'DummyJob') + MissionControl::SolidQueueFailedExecution.create!(job_id: job.id, created_at: 2.seconds.ago) + MissionControl::SolidQueueJob.create!(queue_name: 'default', class_name: 'DummyJob', finished_at: 3.seconds.ago) + + get @base_url, params: { uptime: 5 } + assert_response :ok + + response_data = JSON.parse(response.body) + uptime = response_data["uptime"] + + assert_equal 1, uptime["failed"] + assert_equal 1, uptime["finished"] + end + + test "index handles custom uptime parameter" do + job = MissionControl::SolidQueueJob.create!(queue_name: 'default', class_name: 'DummyJob', finished_at: Time.now) + MissionControl::SolidQueueFailedExecution.create!(job_id: job.id, created_at: 10.seconds.ago) + + get @base_url, params: { uptime: 15 } + assert_response :ok + + response_data = JSON.parse(response.body) + uptime = response_data["uptime"] + + assert_equal 0, uptime["pending"] + assert_equal 1, uptime["failed"] + assert_equal 1, uptime["finished"] + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index bb458fbd..7612931f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -113,4 +113,15 @@ def schedule_recurring_tasks_async(wait: 1.second) yield if block_given? @scheduler.stop end + + def await_perform_all_enqueued_jobs + @worker.start + + while ActiveJob.jobs.pending.count > 0 do + sleep(1.second) + end + + yield if block_given? + @worker.stop + end end