diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c1b15..11e7a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- i18n support — all UI strings are now extracted into `config/locales/en.yml`; locale switching via `?locale=` param or session is supported; host apps can add additional locales using `SolidQueueWeb.available_locales = %i[en es]`; the English locale file can be used as a template for translations + ## [1.5.0] - 2026-05-28 ### Added diff --git a/README.md b/README.md index df0e8b6..9757e99 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch - **Failed job trend chart** — a "Failures — Last 12 Hours" bar chart on the dashboard shows failures per hour over the last 12 hours; bars are red, making failure spikes visible before clicking into the failed jobs list - **Error frequency report** — `GET /jobs/failed_jobs/errors` groups all failed jobs by error class and message prefix, shows a count per group, and surfaces a sample backtrace in an expandable row; sorted by count descending so the most common errors appear first; accessible via the "Error Summary" button on the Failed Jobs page - **Metrics / health endpoint** — `GET /jobs/metrics.json` returns a machine-readable JSON document with job counts, throughput, per-queue depth and pause state, and process health summary; suitable for Prometheus scraping, uptime monitors, or external dashboards; `slow_jobs` count included when `slow_job_threshold` is configured +- **i18n** — all UI strings (page titles, table headers, buttons, empty states, flash messages) are backed by `config/locales/en.yml`; locale switching via `?locale=` param or session; add a custom locale by supplying a YAML file in your host app and registering it with `config.available_locales` ## Compatibility @@ -115,6 +116,7 @@ SolidQueueWeb.configure do |config| config.current_actor = -> { current_user&.email } # identity for audit log (default: nil) config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil) config.time_zone = "America/New_York" # display timezone for all timestamps (default: nil = UTC) + config.available_locales = [:en, :fr] # locales available for switching (default: [:en]) end SolidQueueWeb.authenticate do @@ -339,6 +341,31 @@ config.connects_to = { role: :writing } When `connects_to` is `nil` (the default), no connection switching occurs and single-database apps are unaffected. +## i18n + +All dashboard UI strings — page titles, table headers, button labels, empty states, and flash messages — are backed by `config/locales/en.yml` in the gem. The engine ships with **English (`en`)** only. + +The selected locale is stored in the session and applied via `I18n.with_locale`, so it persists across requests without touching the host application's locale. The `?locale=` query param takes precedence over the session value, making it easy to deep-link to a specific language. + +```ruby +SolidQueueWeb.configure do |config| + # Locales available for switching (default: [:en]). + config.available_locales = [:en, :fr] +end +``` + +### Adding a custom locale + +1. Create a locale file in your host application under `config/locales/`, e.g. `config/locales/solid_queue_web.fr.yml`. +2. Nest all keys under `fr > solid_queue_web:` — use [`config/locales/en.yml`](config/locales/en.yml) in the gem as a reference for the full key list. +3. Register the locale: + +```ruby +config.available_locales = [:en, :fr] +``` + +Rails will pick up the file automatically via its standard `config.i18n.load_path`; no additional configuration is needed. + ## Roadmap See [ROADMAP.md](ROADMAP.md) for the full post-1.0 feature plan, organized by release milestone. diff --git a/ROADMAP.md b/ROADMAP.md index 3ce3a72..89ee240 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,5 @@ Pull requests for any of these are welcome. See [Contributing](README.md#contrib | Feature | Notes | |---|---| -| **i18n / locale support** | Wrap all user-visible strings in `I18n.t`. Makes the gem usable for non-English apps. | | **Custom dashboard cards** | Registration hook so host apps can add their own stat cards alongside queue stats. | | **Custom nav links** | `config.nav_links = [{ label: "Admin", url: "/admin" }]` to integrate the dashboard into the host app's navigation. | diff --git a/app/controllers/solid_queue_web/application_controller.rb b/app/controllers/solid_queue_web/application_controller.rb index 54b94a5..23acb7b 100644 --- a/app/controllers/solid_queue_web/application_controller.rb +++ b/app/controllers/solid_queue_web/application_controller.rb @@ -8,10 +8,20 @@ class ApplicationController < ActionController::Base STAGGER_INTERVALS = { "5s" => 5.seconds, "10s" => 10.seconds, "30s" => 30.seconds, "1m" => 1.minute }.freeze before_action :authenticate! + around_action :with_locale around_action :with_database_connection private + def with_locale + available = SolidQueueWeb.available_locales.map(&:to_s) + locale = params[:locale].presence_in(available) || + session[:solid_queue_web_locale].presence_in(available) || + I18n.default_locale.to_s + session[:solid_queue_web_locale] = locale + I18n.with_locale(locale) { yield } + end + def with_database_connection config = SolidQueueWeb.connects_to return yield unless config diff --git a/app/controllers/solid_queue_web/blocked_jobs_controller.rb b/app/controllers/solid_queue_web/blocked_jobs_controller.rb index 4a25db6..f44db91 100644 --- a/app/controllers/solid_queue_web/blocked_jobs_controller.rb +++ b/app/controllers/solid_queue_web/blocked_jobs_controller.rb @@ -3,9 +3,9 @@ class BlockedJobsController < ApplicationController def destroy jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job) SolidQueue::BlockedExecution.discard_all_from_jobs(jobs) - redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded." + redirect_to root_path, notice: t("solid_queue_web.flash.blocked_jobs_discarded", count: jobs.size) rescue => e - redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}" + redirect_to root_path, alert: t("solid_queue_web.flash.cannot_discard_blocked_jobs", error: e.message) end end end diff --git a/app/controllers/solid_queue_web/failed_jobs/arguments_controller.rb b/app/controllers/solid_queue_web/failed_jobs/arguments_controller.rb index 1384185..09618da 100644 --- a/app/controllers/solid_queue_web/failed_jobs/arguments_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs/arguments_controller.rb @@ -5,11 +5,11 @@ def update new_arguments = JSON.parse(params[:arguments]) execution.job.update!(arguments: new_arguments) execution.retry - redirect_to failed_jobs_path, notice: "Job arguments updated and queued for retry." + redirect_to failed_jobs_path, notice: t("solid_queue_web.flash.arguments_updated") rescue JSON::ParserError - redirect_to job_path(execution.job), alert: "Invalid JSON: could not parse arguments." + redirect_to job_path(execution.job), alert: t("solid_queue_web.flash.invalid_json") rescue => e - redirect_to failed_jobs_path, alert: "Could not update job: #{e.message}" + redirect_to failed_jobs_path, alert: t("solid_queue_web.flash.cannot_update_job", error: e.message) end end end diff --git a/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb b/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb index ad96599..8bde269 100644 --- a/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb @@ -8,9 +8,9 @@ def create SolidQueue::FailedExecution.retry_all(jobs) record_audit("failed_jobs_retried", item_count: jobs.size) redirect_to failed_jobs_path, - notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry." + notice: t("solid_queue_web.flash.jobs_retried", count: jobs.size) rescue => e - redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}" + redirect_to failed_jobs_path, alert: t("solid_queue_web.flash.cannot_retry_jobs", error: e.message) end def destroy @@ -20,9 +20,9 @@ def destroy SolidQueue::FailedExecution.discard_all_from_jobs(jobs) record_audit("failed_jobs_discarded", item_count: jobs.size) redirect_to failed_jobs_path, - notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + notice: t("solid_queue_web.flash.jobs_discarded", count: jobs.size) rescue => e - redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}" + redirect_to failed_jobs_path, alert: t("solid_queue_web.flash.cannot_discard_jobs", error: e.message) end end end diff --git a/app/controllers/solid_queue_web/failed_jobs_controller.rb b/app/controllers/solid_queue_web/failed_jobs_controller.rb index d867ce7..47b8398 100644 --- a/app/controllers/solid_queue_web/failed_jobs_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs_controller.rb @@ -17,7 +17,7 @@ def destroy executions = params[:id] ? [SolidQueue::FailedExecution.find(params[:id])] : filtered_scope.to_a perform_discard(executions) rescue => e - redirect_to failed_jobs_path, alert: "Could not discard job: #{e.message}" + redirect_to failed_jobs_path, alert: t("solid_queue_web.flash.cannot_discard_job", error: e.message) end private @@ -41,7 +41,7 @@ def perform_discard(executions) SolidQueue::FailedExecution.discard_all_from_jobs(jobs) record_audit(action, job_class: jobs.first&.class_name, queue_name: jobs.first&.queue_name, item_count: jobs.size) redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period), - notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + notice: t("solid_queue_web.flash.jobs_discarded", count: jobs.size) end def sortable_columns diff --git a/app/controllers/solid_queue_web/jobs/selections_controller.rb b/app/controllers/solid_queue_web/jobs/selections_controller.rb index 3b4ed78..d1f3aba 100644 --- a/app/controllers/solid_queue_web/jobs/selections_controller.rb +++ b/app/controllers/solid_queue_web/jobs/selections_controller.rb @@ -4,18 +4,18 @@ class SelectionsController < ApplicationController def destroy status = params[:status] period = params[:period].presence_in(PERIOD_DURATIONS.keys) - raise ArgumentError, "Cannot discard #{status} jobs." unless Job::DISCARDABLE.include?(status) + raise ArgumentError, t("solid_queue_web.flash.cannot_discard", status: status) unless Job::DISCARDABLE.include?(status) model = Job::EXECUTION_MODELS[status] ids = Array(params[:ids]).map(&:to_i).reject(&:zero?) jobs = model.where(id: ids).includes(:job).map(&:job) model.discard_all_from_jobs(jobs) record_audit("jobs_discarded", item_count: jobs.size) redirect_to jobs_path(status: status, period: period), - notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + notice: t("solid_queue_web.flash.jobs_discarded", count: jobs.size) rescue ArgumentError => e redirect_to jobs_path(status: status), alert: e.message rescue => e - redirect_to jobs_path(status: status), alert: "Could not discard jobs: #{e.message}" + redirect_to jobs_path(status: status), alert: t("solid_queue_web.flash.cannot_discard_jobs", error: e.message) end end end diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index ac3bbf5..015765f 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -36,18 +36,19 @@ def destroy @remaining_count = filtered_scope(model).count respond_to do |format| format.turbo_stream - format.html { redirect_to jobs_return_path, notice: "Job discarded." } + format.html { redirect_to jobs_return_path, notice: t("solid_queue_web.flash.job_discarded") } end else jobs = filtered_scope(model).map(&:job) model.discard_all_from_jobs(jobs) record_audit("jobs_discarded", item_count: jobs.size) - redirect_to jobs_return_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + redirect_to jobs_return_path, notice: t("solid_queue_web.flash.jobs_discarded", count: jobs.size) end rescue ArgumentError => e redirect_to jobs_return_path, alert: e.message rescue => e - redirect_to jobs_return_path, alert: "Could not discard #{params[:id] ? "job" : "jobs"}: #{e.message}" + msg = params[:id] ? t("solid_queue_web.flash.cannot_discard_job", error: e.message) : t("solid_queue_web.flash.cannot_discard_jobs", error: e.message) + redirect_to jobs_return_path, alert: msg end private diff --git a/app/controllers/solid_queue_web/queues/jobs_controller.rb b/app/controllers/solid_queue_web/queues/jobs_controller.rb index 3f167bc..e48e53f 100644 --- a/app/controllers/solid_queue_web/queues/jobs_controller.rb +++ b/app/controllers/solid_queue_web/queues/jobs_controller.rb @@ -21,19 +21,19 @@ def destroy @remaining_count = filtered_scope(model).count respond_to do |format| format.turbo_stream - format.html { redirect_to queue_jobs_path(queue_name: @queue, status: @status), notice: "Job discarded." } + format.html { redirect_to queue_jobs_path(queue_name: @queue, status: @status), notice: t("solid_queue_web.flash.job_discarded") } end else jobs = filtered_scope(model).map(&:job) model.discard_all_from_jobs(jobs) redirect_to queue_jobs_path(queue_name: @queue, status: @status), - notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + notice: t("solid_queue_web.flash.jobs_discarded", count: jobs.size) end rescue ArgumentError => e redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message rescue => e - redirect_to queue_jobs_path(queue_name: @queue, status: @status), - alert: "Could not discard #{params[:id] ? "job" : "jobs"}: #{e.message}" + msg = params[:id] ? t("solid_queue_web.flash.cannot_discard_job", error: e.message) : t("solid_queue_web.flash.cannot_discard_jobs", error: e.message) + redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: msg end private @@ -51,7 +51,7 @@ def filtered_scope(model) end def execution_model_for!(status) - raise ArgumentError, "Cannot discard #{status} jobs from this page." unless Job::DISCARDABLE.include?(status) + raise ArgumentError, t("solid_queue_web.flash.cannot_discard_from_queue", status: status) unless Job::DISCARDABLE.include?(status) Job::EXECUTION_MODELS[status] end end diff --git a/app/controllers/solid_queue_web/queues/pauses_controller.rb b/app/controllers/solid_queue_web/queues/pauses_controller.rb index bc6f196..ea6f7c7 100644 --- a/app/controllers/solid_queue_web/queues/pauses_controller.rb +++ b/app/controllers/solid_queue_web/queues/pauses_controller.rb @@ -5,18 +5,18 @@ def create queue = SolidQueue::Queue.find_by_name(params[:queue_name]) queue.pause record_audit("queue_paused", queue_name: queue.name) - redirect_to queues_path, notice: "Queue \"#{queue.name}\" paused." + redirect_to queues_path, notice: t("solid_queue_web.flash.queue_paused", name: queue.name) rescue => e - redirect_to queues_path, alert: "Could not pause queue: #{e.message}" + redirect_to queues_path, alert: t("solid_queue_web.flash.cannot_pause_queue", error: e.message) end def destroy queue = SolidQueue::Queue.find_by_name(params[:queue_name]) queue.resume record_audit("queue_resumed", queue_name: queue.name) - redirect_to queues_path, notice: "Queue \"#{queue.name}\" resumed." + redirect_to queues_path, notice: t("solid_queue_web.flash.queue_resumed", name: queue.name) rescue => e - redirect_to queues_path, alert: "Could not resume queue: #{e.message}" + redirect_to queues_path, alert: t("solid_queue_web.flash.cannot_resume_queue", error: e.message) end end end diff --git a/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb b/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb index 73461c4..3987597 100644 --- a/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb +++ b/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb @@ -5,14 +5,14 @@ def create result = task.enqueue(at: Time.current) if result - redirect_to recurring_tasks_path, notice: "\"#{task.key}\" queued for immediate execution." + redirect_to recurring_tasks_path, notice: t("solid_queue_web.flash.task_queued", key: task.key) else - redirect_to recurring_tasks_path, alert: "Could not enqueue \"#{task.key}\" — it may have just run." + redirect_to recurring_tasks_path, alert: t("solid_queue_web.flash.cannot_enqueue_task", key: task.key) end rescue ActiveRecord::RecordNotFound - redirect_to recurring_tasks_path, alert: "Recurring task not found." + redirect_to recurring_tasks_path, alert: t("solid_queue_web.flash.task_not_found") rescue => e - redirect_to recurring_tasks_path, alert: "Could not run task: #{e.message}" + redirect_to recurring_tasks_path, alert: t("solid_queue_web.flash.cannot_run_task", error: e.message) end end end diff --git a/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb b/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb index 36a9f44..1aab6af 100644 --- a/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb +++ b/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb @@ -8,7 +8,7 @@ def create if params[:stagger].present? && executions.size > 1 interval = STAGGER_INTERVALS[params[:stagger]] - raise ArgumentError, "Invalid stagger interval." unless interval + raise ArgumentError, t("solid_queue_web.flash.invalid_stagger") unless interval executions.each_with_index do |execution, i| execution.job.update!(scheduled_at: i.zero? ? nil : Time.current + (i * interval)) execution.retry @@ -23,17 +23,16 @@ def create rescue ArgumentError => e redirect_to failed_jobs_path, alert: e.message rescue => e - redirect_to failed_jobs_path, alert: "Could not retry job: #{e.message}" + redirect_to failed_jobs_path, alert: t("solid_queue_web.flash.cannot_retry_job", error: e.message) end private def retry_notice(count) - label = "#{count} #{"job".pluralize(count)}" if params[:stagger].present? && count > 1 - "#{label} queued for retry, staggered #{params[:stagger]} apart." + t("solid_queue_web.flash.jobs_retried_staggered", count: count, stagger: params[:stagger]) else - "#{label} queued for retry." + t("solid_queue_web.flash.jobs_retried", count: count) end end diff --git a/app/controllers/solid_queue_web/scheduled_jobs_controller.rb b/app/controllers/solid_queue_web/scheduled_jobs_controller.rb index 14f62b8..8d7463b 100644 --- a/app/controllers/solid_queue_web/scheduled_jobs_controller.rb +++ b/app/controllers/solid_queue_web/scheduled_jobs_controller.rb @@ -8,10 +8,10 @@ def create SolidQueue::Job.where(id: job_ids).update_all(scheduled_at: 1.second.ago) redirect_to jobs_path(status: "scheduled", period: @period), - notice: "#{job_ids.size} #{"job".pluralize(job_ids.size)} scheduled to run immediately." + notice: t("solid_queue_web.flash.jobs_run_immediately", count: job_ids.size) rescue => e redirect_to jobs_path(status: "scheduled", period: @period), - alert: "Could not run jobs: #{e.message}" + alert: t("solid_queue_web.flash.cannot_run_jobs", error: e.message) end def update @@ -26,14 +26,14 @@ def update respond_to do |format| format.turbo_stream format.html do - notice = @run_now ? "Job scheduled to run immediately." : "Job rescheduled by +#{params[:offset]}." + notice = @run_now ? t("solid_queue_web.flash.job_run_immediately") : t("solid_queue_web.flash.job_rescheduled", offset: params[:offset]) redirect_to jobs_path(status: "scheduled", period: @period), notice: notice end end rescue ArgumentError => e redirect_to jobs_path(status: "scheduled"), alert: e.message rescue => e - redirect_to jobs_path(status: "scheduled"), alert: "Could not reschedule job: #{e.message}" + redirect_to jobs_path(status: "scheduled"), alert: t("solid_queue_web.flash.cannot_reschedule_job", error: e.message) end private @@ -46,7 +46,7 @@ def scheduled_scope def resolve_new_time(execution, offset) return 1.second.ago if offset == "now" - raise ArgumentError, "Invalid offset." unless PERIOD_DURATIONS.key?(offset) + raise ArgumentError, t("solid_queue_web.flash.invalid_offset") unless PERIOD_DURATIONS.key?(offset) execution.scheduled_at + PERIOD_DURATIONS[offset] end diff --git a/app/views/layouts/solid_queue_web/application.html.erb b/app/views/layouts/solid_queue_web/application.html.erb index 412d74a..dab2e8c 100644 --- a/app/views/layouts/solid_queue_web/application.html.erb +++ b/app/views/layouts/solid_queue_web/application.html.erb @@ -1,9 +1,9 @@ - + - Solid Queue Dashboard + <%= t("solid_queue_web.layout.title") %> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= inline_styles %> @@ -13,27 +13,27 @@
- <%= link_to "Solid Queue", root_path, class: "sqd-header__title" %> + <%= link_to t("solid_queue_web.layout.logo"), root_path, class: "sqd-header__title" %>
-
- -