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

## [Unreleased]

### Added

- Solid Cache entry browser — `GET /cache/entries` lists all `SolidCache::Entry` records in a paginated, sortable table (key, byte size, created-at); sortable by any column; key-substring search filters the list; per-row **Delete** removes a single entry; **Flush All** header button deletes every entry with a confirmation prompt; Cache subnav (Overview / Entries) added to the layout

## [0.4.0] - 2026-05-26

### Added
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,13 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru

## Solid Cache

_Deep cache monitoring coming in v0.5.0. Currently shows entry count and total byte size on the overview dashboard._
### Features

- **Overview dashboard card** — live entry count and total byte size
- **Entry browser** — `GET /cache/entries` lists all `SolidCache::Entry` records in a paginated, sortable table; columns: key, byte size, created-at; sortable by any column
- **Key search** — filter entries by key substring
- **Delete entry** — per-row delete button removes a single cache entry
- **Flush All** — header button deletes every cache entry with a confirmation prompt

---

Expand Down
8 changes: 6 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das
> _Move beyond a single count; give operators visibility into what's in the cache._

### Added
- **Entry browser** — paginated table of `SolidCache::Entry` records with key, byte size, created_at, and last accessed_at; sorted by size or recency
- **Key-pattern search** — filter entries by key prefix or substring
- **Delete entry** — remove a single cache entry from the browser
- **Flush actions** — "Flush expired" (entries past their TTL), "Flush all" (with confirmation prompt)
- **Size distribution stats** — top-N entries by byte size; histogram bucketed by size range
- **Entry detail** — view the serialised value of a single cache entry (with a configurable `allow_value_preview` toggle for sensitive data)
- **Cache timeline** — 24-hour chart of entry count and total byte size growth
- **Stats dashboard card** — expand the overview card to include hit/miss rates if `SolidCache` exposes them, plus oldest-entry age
- **Size distribution stats** — top-N entries by byte size; histogram bucketed by size range
- **Entry detail** — view the serialised value of a single cache entry (with a configurable `allow_value_preview` toggle for sensitive data)
- **Delete entry** — remove a single cache entry from the browser
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/solid_stack_web/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class ApplicationController < ActionController::Base
def current_section
case controller_name
when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks" then :queue
when "cache" then :cache
when "cache", "cache_entries" then :cache
when "cable" then :cable
else :overview
end
Expand Down
8 changes: 8 additions & 0 deletions app/controllers/solid_stack_web/cache/flushes_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module SolidStackWeb
class Cache::FlushesController < ApplicationController
def destroy
::SolidCache::Entry.delete_all
redirect_to cache_entries_path, notice: "All cache entries flushed."
end
end
end
28 changes: 28 additions & 0 deletions app/controllers/solid_stack_web/cache_entries_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module SolidStackWeb
class CacheEntriesController < ApplicationController
def index
@search = params[:q].presence
@sort = resolve_sort

scope = ::SolidCache::Entry.all
scope = scope.where("key LIKE ?", "%#{::ActiveRecord::Base.sanitize_sql_like(@search)}%") if @search
scope = scope.order(@sort["column"] => @sort["direction"])

@pagy, @entries = pagy(scope)
end

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."
end

private

def resolve_sort
column = %w[byte_size created_at key].include?(params[:column]) ? params[:column] : "byte_size"
direction = %w[asc desc].include?(params[:direction]) ? params[:direction] : "desc"
{ "column" => column, "direction" => direction }
end
end
end
2 changes: 2 additions & 0 deletions app/javascript/solid_stack_web/application.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import "@hotwired/turbo"
import { Application } from "@hotwired/stimulus"
import RefreshController from "solid_stack_web/refresh_controller"
import SearchController from "solid_stack_web/search_controller"
import SelectionController from "solid_stack_web/selection_controller"
import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"

const application = Application.start()
application.register("refresh", RefreshController)
application.register("search", SearchController)
application.register("selection", SelectionController)
application.register("sparkline-tooltip", SparklineTooltipController)
16 changes: 16 additions & 0 deletions app/javascript/solid_stack_web/search_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
filter({ target }) {
clearTimeout(this._timer)
const len = target.value.length
if (len >= 4 || len === 0) {
this._timer = setTimeout(() => target.form.requestSubmit(), 300)
}
}

select({ target }) {
clearTimeout(this._timer)
target.form.requestSubmit()
}
}
11 changes: 11 additions & 0 deletions app/views/layouts/solid_stack_web/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@
</div>
</header>

<% if current_section == :cache %>
<nav class="sqw-subnav">
<div class="sqw-subnav__inner">
<%= link_to "Overview", cache_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cache"}" %>
<%= link_to "Entries", cache_entries_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cache_entries"}" %>
</div>
</nav>
<% end %>

<% if current_section == :queue %>
<nav class="sqw-subnav">
<div class="sqw-subnav__inner">
Expand Down
64 changes: 64 additions & 0 deletions app/views/solid_stack_web/cache_entries/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<div class="sqw-page-header sqw-page-header--split">
<h1 class="sqw-page-title">Cache Entries</h1>
<div class="sqw-header-actions">
<%= button_to "Flush All",
cache_flush_path,
method: :delete,
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Delete all cache entries? This cannot be undone." } %>
</div>
</div>

<form class="sqw-filters" action="<%= cache_entries_path %>" method="get" data-controller="search">
<%= hidden_field_tag :column, @sort["column"] %>
<%= hidden_field_tag :direction, @sort["direction"] %>
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by key…" autocomplete="off" aria-label="Filter by key"
data-action="input->search#filter">
<% if @search.present? %>
<%= link_to "Clear", cache_entries_path(column: @sort["column"], direction: @sort["direction"]),
class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
<% end %>
</form>

<% if @entries.any? %>
<table class="sqw-table">
<thead>
<tr>
<% [["key", "Key"], ["byte_size", "Size"], ["created_at", "Created"]].each do |col, label| %>
<th>
<% next_dir = (@sort["column"] == col && @sort["direction"] == "desc") ? "asc" : "desc" %>
<%= link_to cache_entries_path(q: @search, column: col, direction: next_dir) do %>
<%= label %>
<% if @sort["column"] == col %>
<span class="sqw-sort-indicator"><%= @sort["direction"] == "desc" ? "↓" : "↑" %></span>
<% end %>
<% end %>
</th>
<% end %>
<th></th>
</tr>
</thead>
<tbody>
<% @entries.each do |entry| %>
<tr id="cache_entry_<%= entry.id %>">
<td class="sqw-monospace sqw-truncate" title="<%= entry.key %>"><%= entry.key %></td>
<td><%= number_to_human_size(entry.byte_size) %></td>
<td class="sqw-muted"><%= entry.created_at.strftime("%b %d %H:%M") %></td>
<td class="sqw-actions">
<%= button_to "Delete",
cache_entry_path(entry, q: @search, column: @sort["column"], direction: @sort["direction"]),
method: :delete,
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Delete this cache entry?" } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
<% else %>
<div class="sqw-empty">
<p><%= @search.present? ? "No entries matching &ldquo;#{@search}&rdquo;.".html_safe : "No cache entries." %></p>
</div>
<% end %>
6 changes: 3 additions & 3 deletions app/views/solid_stack_web/history/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
</div>
</div>

<form class="sqw-filters" action="<%= history_path %>" method="get">
<form class="sqw-filters" action="<%= history_path %>" method="get" data-controller="search">
<% if @queue.present? %>
<input type="hidden" name="queue" value="<%= @queue %>">
<% end %>
<input type="hidden" name="period" value="<%= @period %>">
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class">
<button type="submit" class="sqw-btn sqw-btn--muted sqw-btn--sm">Search</button>
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
data-action="input->search#filter">
<% if @search.present? %>
<%= link_to "Clear", history_path(queue: @queue, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
<% end %>
Expand Down
12 changes: 7 additions & 5 deletions app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,30 @@
<%= turbo_frame_tag "sqw-jobs-filter",
data: { turbo_action: "advance", controller: "refresh",
refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
<form class="sqw-filters" action="<%= jobs_path %>" method="get">
<form class="sqw-filters" action="<%= jobs_path %>" method="get" data-controller="search">
<%= hidden_field_tag :status, @status %>
<%= hidden_field_tag :period, @period %>
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class">
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
data-action="input->search#filter">
<% if @queue_options.size > 1 %>
<select name="queue" class="sqw-select" aria-label="Filter by queue" onchange="this.form.requestSubmit()">
<select name="queue" class="sqw-select" aria-label="Filter by queue"
data-action="change->search#select">
<option value="">All queues</option>
<% @queue_options.each do |q| %>
<option value="<%= q %>" <%= "selected" if @queue == q %>><%= q %></option>
<% end %>
</select>
<% end %>
<% if @priority_options.size > 1 %>
<select name="priority" class="sqw-select" aria-label="Filter by priority" onchange="this.form.requestSubmit()">
<select name="priority" class="sqw-select" aria-label="Filter by priority"
data-action="change->search#select">
<option value="">All priorities</option>
<% @priority_options.each do |p| %>
<option value="<%= p %>" <%= "selected" if @priority.to_s == p.to_s %>>Priority <%= p %></option>
<% end %>
</select>
<% end %>
<button type="submit" class="sqw-btn sqw-btn--muted sqw-btn--sm">Search</button>
<% if @search.present? || @queue.present? || @priority.present? %>
<%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
<% end %>
Expand Down
1 change: 1 addition & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
pin "solid_stack_web", to: "solid_stack_web/application.js"
pin "solid_stack_web/refresh_controller", to: "solid_stack_web/refresh_controller.js"
pin "solid_stack_web/search_controller", to: "solid_stack_web/search_controller.js"
pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
pin "solid_stack_web/sparkline_tooltip_controller", to: "solid_stack_web/sparkline_tooltip_controller.js"
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,7 @@
get "stats", to: "stats#index", as: :stats
get "history", to: "history#index", as: :history
get "cache", to: "cache#index", as: :cache
resources :cache_entries, only: [:index, :destroy], path: "cache/entries"
resource :cache_flush, only: [:destroy], path: "cache/flush", controller: "cache/flushes"
get "cable", to: "cable#index", as: :cable
end
71 changes: 71 additions & 0 deletions spec/requests/solid_stack_web/cache_entries_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
require "rails_helper"

RSpec.describe "CacheEntries", type: :request do
let(:engine_root) { "/solid_stack" }

def create_entry(key: "mykey", value: "myval")
SolidCache::Entry.write(key, value)
SolidCache::Entry.order(created_at: :desc).first
end

describe "GET /cache/entries" do
it "returns 200" do
get "#{engine_root}/cache/entries"
expect(response).to have_http_status(:ok)
end

it "lists entries sorted by byte_size desc by default" do
create_entry(key: "small", value: "x")
create_entry(key: "large", value: "x" * 100)
get "#{engine_root}/cache/entries"
expect(response.body.index("large")).to be < response.body.index("small")
end

it "filters entries by key substring" do
create_entry(key: "alpha:1", value: "a")
create_entry(key: "beta:1", value: "b")
get "#{engine_root}/cache/entries", params: { q: "alpha" }
expect(response.body).to include("alpha:1")
expect(response.body).not_to include("beta:1")
end

it "shows empty state when no entries exist" do
get "#{engine_root}/cache/entries"
expect(response.body).to include("No cache entries")
end

it "shows empty state when search yields no results" do
create_entry(key: "some:key", value: "v")
get "#{engine_root}/cache/entries", params: { q: "nope" }
expect(response.body).to include("No entries matching")
end

it "sorts by created_at when requested" do
get "#{engine_root}/cache/entries", params: { column: "created_at", direction: "asc" }
expect(response).to have_http_status(:ok)
end

it "ignores invalid sort column and falls back to byte_size" do
get "#{engine_root}/cache/entries", params: { column: "evil; DROP TABLE", direction: "desc" }
expect(response).to have_http_status(:ok)
end
end

describe "DELETE /cache/entries/:id" do
it "deletes the entry and redirects" do
entry = create_entry
delete "#{engine_root}/cache/entries/#{entry.id}"
expect(response).to redirect_to("#{engine_root}/cache/entries")
expect(SolidCache::Entry.exists?(entry.id)).to be false
end
end

describe "DELETE /cache/flush" do
it "deletes all entries and redirects" do
3.times { |i| create_entry(key: "key#{i}", value: "v") }
delete "#{engine_root}/cache/flush"
expect(response).to redirect_to("#{engine_root}/cache/entries")
expect(SolidCache::Entry.count).to eq(0)
end
end
end
6 changes: 5 additions & 1 deletion spec/requests/solid_stack_web/failed_jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,11 @@ def create_failed(class_name: "FailingJob", queue_name: "default")

it "falls back to raw arguments string when JSON generation fails" do
execution = create_failed
allow(JSON).to receive(:pretty_generate).and_raise(JSON::GeneratorError, "NaN")
job_args = execution.job.arguments
allow(JSON).to receive(:pretty_generate).and_wrap_original do |m, obj, *rest|
raise JSON::GeneratorError, "NaN" if obj == job_args
m.call(obj, *rest)
end

get "#{engine_root}/failed_jobs/#{execution.id}"

Expand Down
13 changes: 13 additions & 0 deletions spec/solid_stack_web_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,17 @@
SolidStackWeb.page_size = 25
end
end

describe ".search_results_limit" do
it "defaults to 25" do
expect(SolidStackWeb.search_results_limit).to eq(25)
end

it "returns the configured value" do
SolidStackWeb.search_results_limit = 50
expect(SolidStackWeb.search_results_limit).to eq(50)
ensure
SolidStackWeb.search_results_limit = nil
end
end
end