Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- i18n support — all UI labels, flash messages, table headers, empty states, confirmations, and sparkline tooltips are now backed by locale YAML files; ships with English (`en`) and Spanish (`es`) out of the box
- Locale switcher — a `<select>` dropdown in the header lets users switch languages at runtime; locale is stored in the session and applied via `I18n.with_locale`; automatically hidden when only one locale is configured
- `config.available_locales` — controls which locales appear in the switcher (default: `[:en, :es]`)

## [1.5.0] - 2026-05-29

### Changed
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ CHECKSUMS
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://www.ruby-lang.org)
[![codecov](https://codecov.io/gh/eclectic-coding/solid_stack_web/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/solid_stack_web)

A production-ready operations dashboard for the full Rails Solid Stack. Mount one engine to get deep visibility into **Solid Queue** (job browser, failed job retry, queue controls, recurring tasks, performance stats), **Solid Cache** (entry browser, size distribution, write timeline), and **Solid Cable** (channel browser, message list, purge controls) — with dark mode, CSV export, alert webhooks, and a JSON metrics endpoint, all with no asset pipeline dependency.
A production-ready operations dashboard for the full Rails Solid Stack. Mount one engine to get deep visibility into **Solid Queue** (job browser, failed job retry, queue controls, recurring tasks, performance stats), **Solid Cache** (entry browser, size distribution, write timeline), and **Solid Cable** (channel browser, message list, purge controls) — with dark mode, i18n locale switching, CSV export, alert webhooks, and a JSON metrics endpoint, all with no asset pipeline dependency.

## Installation

Expand Down Expand Up @@ -159,6 +159,7 @@ The dashboard is designed to be mounted behind your application's existing authe
- **Turbo Stream** job discard — removes the row inline without a full page reload
- **Sticky filter preferences** — last-used status, period, and queue filter saved to `localStorage`; a fresh visit to the jobs or history list with no URL params automatically restores the previous selection
- **Dark mode** — toggle button in the header switches between light and dark palettes; preference persisted in `localStorage`; respects `prefers-color-scheme` on first visit
- **i18n / locale switching** — all UI strings backed by locale YAML files; ships with English (`en`) and Spanish (`es`); a language selector in the header lets users switch at runtime; locale is stored in the session and persists across requests; configure which locales appear via `config.available_locales`
- **Responsive layout** — stats cards, tables, and two-column grids adapt to narrow viewports; tables scroll horizontally rather than overflow; split page headers stack on small screens
- **Empty-state improvements** — all list views show a contextual title and an actionable hint; search empty states include a "Clear search" link; filters-active history view offers "Clear filters"; processes and recurring tasks explain the next step
- **Inline notifications** — bulk and single-job actions surface a flash notice; Turbo Stream discard responses inject the message inline without a full page reload; bulk actions report the affected count ("3 jobs discarded")
Expand Down Expand Up @@ -192,6 +193,12 @@ SolidStackWeb.configure do |config|
# Show the raw serialized value on the cache entry detail page (default: false).
# Disable for stores that contain sensitive data.
config.allow_value_preview = true

# Locales shown in the language switcher (default: [:en, :es]).
# The switcher is hidden when only one locale is configured.
# To add a locale, provide a locale YAML file under your app's config/locales/
# with keys nested under solid_stack_web:, then add the locale symbol here.
config.available_locales = [:en, :es]
end
```

Expand Down
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

> _Breaking changes or large architectural additions._

- **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 inject their own stat cards alongside the built-in queue, cache, and cable cards
- **Custom nav links** — `config.nav_links = [{ label: "Admin", url: "/admin" }]` to integrate the dashboard into the host app's navigation

Expand Down
12 changes: 12 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_02_layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,15 @@
}
.sqw-flash--notice { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; }
.sqw-flash--alert { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }

.sqw-locale-form { display: flex; align-items: center; flex-shrink: 0; }
.sqw-locale-select {
font-size: 12px;
padding: 0.2rem 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
cursor: pointer;
height: 28px;
}
10 changes: 10 additions & 0 deletions app/controllers/solid_stack_web/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class ApplicationController < ActionController::Base
PERIOD_DURATIONS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze

before_action :authenticate!
around_action :with_locale
around_action :with_database_connection

rescue_from StandardError do |exception|
Expand All @@ -29,6 +30,15 @@ def current_section
end
end

def with_locale
available = SolidStackWeb.available_locales.map(&:to_s)
locale = params[:locale].presence_in(available) ||
session[:solid_stack_web_locale].presence_in(available) ||
I18n.default_locale.to_s
session[:solid_stack_web_locale] = locale
I18n.with_locale(locale) { yield }
end

def with_database_connection
config = SolidStackWeb.connects_to
return yield unless config
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/solid_stack_web/audit_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class AuditController < ApplicationController
def index
unless AuditEvent.table_exists?
redirect_to root_path,
alert: "Audit log requires running `rails solid_stack_web:install:migrations && rails db:migrate`."
alert: t("solid_stack_web.flash.audit_migration_required")
return
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module SolidStackWeb
class Cable::ChannelPurgesController < ApplicationController
def destroy
::SolidCable::Message.where(channel_hash: params[:channel_hash]).delete_all
redirect_to cable_path, notice: "All messages for this channel have been purged."
redirect_to cable_path, notice: t("solid_stack_web.flash.channel_purged")
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/solid_stack_web/cable/purges_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class Cable::PurgesController < ApplicationController
def destroy
days = [params[:older_than].to_i, 1].max
::SolidCable::Message.where("created_at < ?", days.days.ago).delete_all
redirect_to cable_path, notice: "Messages older than #{days} #{days == 1 ? "day" : "days"} purged."
redirect_to cable_path, notice: t("solid_stack_web.flash.messages_purged", count: days)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module SolidStackWeb
class Cache::FlushesController < ApplicationController
def destroy
::SolidCache::Entry.delete_all
redirect_to cache_entries_path, notice: "All cache entries flushed."
redirect_to cache_entries_path, notice: t("solid_stack_web.flash.cache_flushed")
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def show
def destroy
::SolidCache::Entry.find(params[:id]).destroy
redirect_to cache_entries_path(q: params[:q], column: params[:column], direction: params[:direction]),
notice: "Cache entry deleted."
notice: t("solid_stack_web.flash.cache_entry_deleted")
end

private
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ def update
new_arguments = JSON.parse(params[:arguments])
@execution.job.update!(arguments: new_arguments)
@execution.retry
redirect_to failed_jobs_path, notice: "Arguments updated and job queued for retry."
redirect_to failed_jobs_path, notice: t("solid_stack_web.flash.arguments_updated")
rescue JSON::ParserError
redirect_to failed_job_path(@execution), alert: "Invalid JSON — arguments were not saved."
redirect_to failed_job_path(@execution), alert: t("solid_stack_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_stack_web.flash.cannot_update_job", error: e.message)
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ def create
count = @ids.size
SolidQueue::FailedExecution.where(id: @ids).each(&:retry)
record_audit("failed_jobs_retried", item_count: count)
redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} retried."
redirect_to failed_jobs_path, notice: t("solid_stack_web.flash.jobs_retried", count: count)
rescue => e
redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
redirect_to failed_jobs_path, alert: t("solid_stack_web.flash.cannot_retry_jobs", error: e.message)
end

def destroy
job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id)
count = SolidQueue::Job.where(id: job_ids).destroy_all.size
record_audit("failed_jobs_discarded", item_count: count)
redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
redirect_to failed_jobs_path, notice: t("solid_stack_web.flash.jobs_discarded", count: count)
rescue => e
redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
redirect_to failed_jobs_path, alert: t("solid_stack_web.flash.cannot_discard_jobs", error: e.message)
end

private
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/solid_stack_web/failed_jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def destroy
@execution.job.destroy!
record_audit("failed_job_discarded", job_class: job_class, queue_name: queue_name)
@executions_remain = ::SolidQueue::FailedExecution.exists?
@notice = "Job discarded."
@notice = t("solid_stack_web.flash.job_discarded")

respond_to do |format|
format.html { redirect_to failed_jobs_path }
Expand All @@ -45,7 +45,7 @@ def retry
execution = ::SolidQueue::FailedExecution.find(params[:id])
record_audit("failed_job_retried", job_class: execution.job.class_name, queue_name: execution.job.queue_name)
execution.retry
redirect_to failed_jobs_path, notice: "Job retried."
redirect_to failed_jobs_path, notice: t("solid_stack_web.flash.job_retried")
end

private
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/solid_stack_web/jobs/selections_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module Jobs
class SelectionsController < ApplicationController
def destroy
status = params[:status].presence_in(Job::STATUSES) || "ready"
raise ArgumentError, "Cannot discard #{status} jobs." unless Job::DISCARDABLE.include?(status)
raise ArgumentError, t("solid_stack_web.flash.cannot_discard", status: status) unless Job::DISCARDABLE.include?(status)

ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
job_ids = Job::EXECUTION_MODELS[status].where(id: ids).pluck(:job_id)
Expand All @@ -18,7 +18,7 @@ def destroy
priority: params[:priority].presence,
sort: params[:sort].presence,
direction: params[:direction].presence
), notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
), notice: t("solid_stack_web.flash.jobs_discarded", count: count)
rescue ArgumentError => e
redirect_to jobs_path(status: params[:status]), alert: e.message
end
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def destroy
@execution.job.destroy!
record_audit("job_discarded", job_class: job_class, queue_name: queue_name)
@executions_remain = Job::EXECUTION_MODELS[@status].exists?
@notice = "Job discarded."
@notice = t("solid_stack_web.flash.job_discarded")

respond_to do |format|
format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction) }
Expand All @@ -47,7 +47,7 @@ def destroy
count = SolidQueue::Job.where(id: job_ids).destroy_all.size
record_audit("jobs_discarded", item_count: count)
redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction),
notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
notice: t("solid_stack_web.flash.jobs_discarded", count: count)
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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_stack_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_stack_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_stack_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_stack_web.flash.cannot_run_task", error: e.message)
end
end
end
10 changes: 5 additions & 5 deletions app/controllers/solid_stack_web/scheduled_jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ def create
SolidQueue::ScheduledExecution.where(job_id: job_ids).update_all(scheduled_at: 1.second.ago)
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_stack_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_stack_web.flash.cannot_run_jobs", error: e.message)
end

def update
Expand All @@ -24,14 +24,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_stack_web.flash.job_run_immediately") : t("solid_stack_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_stack_web.flash.cannot_reschedule_job", error: e.message)
end

private
Expand All @@ -44,7 +44,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_stack_web.flash.invalid_offset") unless PERIOD_DURATIONS.key?(offset)

execution.scheduled_at + PERIOD_DURATIONS[offset]
end
Expand Down
Loading