diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edfa01..c5549a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Cache entry detail page — `GET /cache/entries/:id` shows the full key, byte size, and created-at for a single entry; value body is gated behind `SolidStackWeb.allow_value_preview` (default `false`); when enabled, displays up to 4 KB of the raw serialized value with a truncation notice; entry key in the browser table is now a link to the detail page; **Delete** button on the detail page redirects back to the entry list - 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 diff --git a/README.md b/README.md index 60e5a22..317ce8f 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ SolidStackWeb.configure do |config| # Maximum results shown by the search feature (default: 25). config.search_results_limit = 25 + + # 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 end ``` @@ -97,9 +101,10 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru ### 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 +- **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 auto-submits search after 4 characters +- **Key search** — filter entries by key substring; results update automatically after 4 characters +- **Entry detail page** — `GET /cache/entries/:id` shows the full key, byte size, and created-at; optionally displays the raw serialized value (see `allow_value_preview` below) +- **Delete entry** — per-row delete button or detail-page button removes a single cache entry - **Flush All** — header button deletes every cache entry with a confirmation prompt --- diff --git a/ROADMAP.md b/ROADMAP.md index 25c064a..5a79172 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das ### Added - **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 diff --git a/app/assets/stylesheets/solid_stack_web/_09_detail.css b/app/assets/stylesheets/solid_stack_web/_09_detail.css index dda42a3..f2463b4 100644 --- a/app/assets/stylesheets/solid_stack_web/_09_detail.css +++ b/app/assets/stylesheets/solid_stack_web/_09_detail.css @@ -70,6 +70,48 @@ overflow-y: auto; } +.sqw-detail { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1.5rem; + font-size: 13px; + margin-bottom: 1.5rem; +} +.sqw-detail__row { + display: contents; +} +.sqw-detail__row dt { color: var(--muted); white-space: nowrap; align-self: start; padding-top: 0.15rem; } +.sqw-detail__row dd { word-break: break-all; } + +.sqw-value-preview { margin-top: 1rem; } + +.sqw-value-preview__header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} +.sqw-value-preview__header .sqw-section-title { margin-bottom: 0; } + +.sqw-value-pre { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 500px; + overflow-y: auto; +} + +.sqw-value-truncated { font-size: 12px; margin-top: 0.5rem; } + +.sqw-link { color: var(--primary); text-decoration: none; } +.sqw-link:hover { text-decoration: underline; } + .sqw-code-input { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 12px; diff --git a/app/controllers/solid_stack_web/cache_entries_controller.rb b/app/controllers/solid_stack_web/cache_entries_controller.rb index f15ecde..ad976ba 100644 --- a/app/controllers/solid_stack_web/cache_entries_controller.rb +++ b/app/controllers/solid_stack_web/cache_entries_controller.rb @@ -11,6 +11,10 @@ def index @pagy, @entries = pagy(scope) end + def show + @entry = ::SolidCache::Entry.find(params[:id]) + end + def destroy ::SolidCache::Entry.find(params[:id]).destroy redirect_to cache_entries_path(q: params[:q], column: params[:column], direction: params[:direction]), diff --git a/app/helpers/solid_stack_web/application_helper.rb b/app/helpers/solid_stack_web/application_helper.rb index b17288d..73adc1d 100644 --- a/app/helpers/solid_stack_web/application_helper.rb +++ b/app/helpers/solid_stack_web/application_helper.rb @@ -1,5 +1,13 @@ module SolidStackWeb module ApplicationHelper + def format_cache_value(raw) + str = raw.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?") + parsed = JSON.parse(str) + { label: "JSON", content: JSON.pretty_generate(parsed) } + rescue JSON::ParserError, JSON::GeneratorError + { label: "Text", content: str } + end + def format_duration(seconds) return "—" if seconds.nil? return "#{(seconds * 1000).round}ms" if seconds < 1 diff --git a/app/views/solid_stack_web/cache_entries/index.html.erb b/app/views/solid_stack_web/cache_entries/index.html.erb index cac1967..25998e9 100644 --- a/app/views/solid_stack_web/cache_entries/index.html.erb +++ b/app/views/solid_stack_web/cache_entries/index.html.erb @@ -42,7 +42,9 @@ <% @entries.each do |entry| %> - <%= entry.key %> + + <%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %> + <%= number_to_human_size(entry.byte_size) %> <%= entry.created_at.strftime("%b %d %H:%M") %> diff --git a/app/views/solid_stack_web/cache_entries/show.html.erb b/app/views/solid_stack_web/cache_entries/show.html.erb new file mode 100644 index 0000000..c87e9f7 --- /dev/null +++ b/app/views/solid_stack_web/cache_entries/show.html.erb @@ -0,0 +1,48 @@ +
+

<%= @entry.key %>

+
+ <%= button_to "Delete", + cache_entry_path(@entry), + method: :delete, + class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Delete this cache entry?" } %> + <%= link_to "← Entries", cache_entries_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> +
+
+ +
+
+
Key
+
<%= @entry.key %>
+
+
+
Size
+
<%= number_to_human_size(@entry.byte_size) %>
+
+
+
Created
+
<%= @entry.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %>
+
+
+ +
+
+

Value

+ <% if SolidStackWeb.allow_value_preview %> + <% formatted = format_cache_value(@entry.value) %> + <%= formatted[:label] %> + <% end %> +
+ <% if SolidStackWeb.allow_value_preview %> + <% content = formatted[:content] %> + <% truncated = content.length > 4096 %> +
<%= truncated ? content[0, 4096] : content %>
+ <% if truncated %> +

Showing first 4 KB of <%= number_to_human_size(@entry.byte_size) %> total.

+ <% end %> + <% else %> +
+

Value preview is disabled. Set config.allow_value_preview = true in your initializer to enable.

+
+ <% end %> +
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index a2408c8..6106fc5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,7 +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" + resources :cache_entries, only: [:index, :show, :destroy], path: "cache/entries" resource :cache_flush, only: [:destroy], path: "cache/flush", controller: "cache/flushes" get "cable", to: "cable#index", as: :cable end diff --git a/lib/solid_stack_web.rb b/lib/solid_stack_web.rb index d233924..e8980d6 100644 --- a/lib/solid_stack_web.rb +++ b/lib/solid_stack_web.rb @@ -7,7 +7,7 @@ class << self :alert_webhook_url, :alert_webhook_cooldown, :alert_failure_threshold, :alert_queue_thresholds, :dashboard_refresh_interval, :default_refresh_interval, - :search_results_limit + :search_results_limit, :allow_value_preview def page_size @page_size || 25 @@ -49,6 +49,10 @@ def search_results_limit @search_results_limit || 25 end + def allow_value_preview + @allow_value_preview || false + end + def configure yield self end diff --git a/spec/dummy/config/initializers/solid_stack_web.rb b/spec/dummy/config/initializers/solid_stack_web.rb new file mode 100644 index 0000000..f69ba95 --- /dev/null +++ b/spec/dummy/config/initializers/solid_stack_web.rb @@ -0,0 +1,3 @@ +SolidStackWeb.configure do |config| + config.allow_value_preview = true +end diff --git a/spec/requests/solid_stack_web/cache_entries_spec.rb b/spec/requests/solid_stack_web/cache_entries_spec.rb index 8087a24..f475e32 100644 --- a/spec/requests/solid_stack_web/cache_entries_spec.rb +++ b/spec/requests/solid_stack_web/cache_entries_spec.rb @@ -51,6 +51,31 @@ def create_entry(key: "mykey", value: "myval") end end + describe "GET /cache/entries/:id" do + it "returns 200 and shows key metadata" do + entry = create_entry(key: "detail:key", value: "hello") + get "#{engine_root}/cache/entries/#{entry.id}" + expect(response).to have_http_status(:ok) + expect(response.body).to include("detail:key") + expect(response.body).to include("Bytes") # number_to_human_size + end + + it "hides value when allow_value_preview is false" do + entry = create_entry(key: "secret:key", value: "sensitive") + allow(SolidStackWeb).to receive(:allow_value_preview).and_return(false) + get "#{engine_root}/cache/entries/#{entry.id}" + expect(response.body).to include("allow_value_preview") + expect(response.body).not_to include("sensitive") + end + + it "shows value when allow_value_preview is true" do + entry = create_entry(key: "visible:key", value: "my_cached_value") + allow(SolidStackWeb).to receive(:allow_value_preview).and_return(true) + get "#{engine_root}/cache/entries/#{entry.id}" + expect(response.body).to include("my_cached_value") + end + end + describe "DELETE /cache/entries/:id" do it "deletes the entry and redirects" do entry = create_entry