From ff910179051b7e33fda6d7728655164eda3228ce Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 10:01:26 -0400 Subject: [PATCH 1/2] feat: cache size distribution stats and largest entries (#27) Adds two sections to the Solid Cache overview page (hidden when empty): - Size Distribution table with 5 byte-range buckets and proportional inline bars showing % of total entries per bucket - Largest Entries table (top 10 by byte_size) with links to detail pages Backed by CacheSizeStats PORO; distribution bars use --purple to match the cache colour theme. Closes #27 Co-Authored-By: Claude Sonnet 4.6 --- .../stylesheets/solid_stack_web/_03_stats.css | 27 ++++++++++ .../solid_stack_web/cache_controller.rb | 1 + .../solid_stack_web/cache_size_stats.rb | 33 ++++++++++++ .../solid_stack_web/cache/index.html.erb | 52 +++++++++++++++++++ spec/requests/solid_stack_web/cache_spec.rb | 29 +++++++++++ 5 files changed, 142 insertions(+) create mode 100644 app/models/solid_stack_web/cache_size_stats.rb diff --git a/app/assets/stylesheets/solid_stack_web/_03_stats.css b/app/assets/stylesheets/solid_stack_web/_03_stats.css index 48b5616..024d560 100644 --- a/app/assets/stylesheets/solid_stack_web/_03_stats.css +++ b/app/assets/stylesheets/solid_stack_web/_03_stats.css @@ -22,6 +22,33 @@ a.sqw-stat:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); text-decoration: none; .sqw-stat__label { font-size: 12px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } .sqw-stat__value { font-size: 28px; font-weight: 700; line-height: 1; } +.sqw-size-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin-top: 1.5rem; +} + +.sqw-size-section { } + +.sqw-dist-bar-cell { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 160px; +} + +.sqw-dist-bar { + height: 8px; + background: var(--purple); + border-radius: 4px; + min-width: 2px; + opacity: 0.7; + transition: width 0.3s ease; +} + +.sqw-dist-pct { font-size: 12px; white-space: nowrap; } + .sqw-stat--ready .sqw-stat__value { color: var(--success); } .sqw-stat--scheduled .sqw-stat__value { color: var(--info); } .sqw-stat--claimed .sqw-stat__value { color: var(--primary); } diff --git a/app/controllers/solid_stack_web/cache_controller.rb b/app/controllers/solid_stack_web/cache_controller.rb index 00d2fa8..4ca6933 100644 --- a/app/controllers/solid_stack_web/cache_controller.rb +++ b/app/controllers/solid_stack_web/cache_controller.rb @@ -3,6 +3,7 @@ class CacheController < ApplicationController def index @total_entries = ::SolidCache::Entry.count @total_byte_size = ::SolidCache::Entry.sum(:byte_size) + @size_stats = CacheSizeStats.new end end end diff --git a/app/models/solid_stack_web/cache_size_stats.rb b/app/models/solid_stack_web/cache_size_stats.rb new file mode 100644 index 0000000..16e8b8b --- /dev/null +++ b/app/models/solid_stack_web/cache_size_stats.rb @@ -0,0 +1,33 @@ +module SolidStackWeb + class CacheSizeStats + BUCKETS = [ + { label: "< 1 KB", min: 0, max: 1_024 }, + { label: "1 – 10 KB", min: 1_024, max: 10_240 }, + { label: "10 – 100 KB", min: 10_240, max: 102_400 }, + { label: "100 KB – 1 MB", min: 102_400, max: 1_048_576 }, + { label: "> 1 MB", min: 1_048_576, max: nil } + ].freeze + + TOP_N = 10 + + def top_entries + @top_entries ||= ::SolidCache::Entry + .select(:id, :key, :byte_size) + .order(byte_size: :desc) + .limit(TOP_N) + end + + def buckets + @buckets ||= BUCKETS.map do |b| + scope = ::SolidCache::Entry.all + scope = scope.where("byte_size >= ?", b[:min]) if b[:min] > 0 + scope = scope.where("byte_size < ?", b[:max]) if b[:max] + { label: b[:label], count: scope.count } + end + end + + def total + @total ||= ::SolidCache::Entry.count + end + end +end diff --git a/app/views/solid_stack_web/cache/index.html.erb b/app/views/solid_stack_web/cache/index.html.erb index cb4f83f..2c68522 100644 --- a/app/views/solid_stack_web/cache/index.html.erb +++ b/app/views/solid_stack_web/cache/index.html.erb @@ -12,3 +12,55 @@ <%= number_to_human_size(@total_byte_size) %> + +<% if @total_entries > 0 %> +
+
+

Size Distribution

+ + + + + + + + + + <% @size_stats.buckets.each do |bucket| %> + <% pct = @size_stats.total > 0 ? (bucket[:count].to_f / @size_stats.total * 100).round(1) : 0 %> + + + + + + <% end %> + +
RangeEntries
<%=bucket[:label] %><%= bucket[:count] %> +
+ <%= pct %>% +
+
+ +
+

Largest Entries

+ + + + + + + + + <% @size_stats.top_entries.each do |entry| %> + + + + + <% end %> + +
KeySize
+ <%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %> + <%= number_to_human_size(entry.byte_size) %>
+
+
+<% end %> \ No newline at end of file diff --git a/spec/requests/solid_stack_web/cache_spec.rb b/spec/requests/solid_stack_web/cache_spec.rb index c2a0b23..8d0124a 100644 --- a/spec/requests/solid_stack_web/cache_spec.rb +++ b/spec/requests/solid_stack_web/cache_spec.rb @@ -31,5 +31,34 @@ expect(response.body).to include("Bytes").or include("KB").or include("MB") end + + it "hides size distribution sections when cache is empty" do + SolidCache::Entry.delete_all + get "#{engine_root}/cache" + expect(response.body).not_to include("Size Distribution") + expect(response.body).not_to include("Largest Entries") + end + + it "shows size distribution and largest entries when entries exist" do + SolidCache::Entry.write("small:key", "x") + SolidCache::Entry.write("large:key", "x" * 2000) + get "#{engine_root}/cache" + expect(response.body).to include("Size Distribution") + expect(response.body).to include("Largest Entries") + end + + it "shows all size bucket labels" do + SolidCache::Entry.write("bucket:key", "hello") + get "#{engine_root}/cache" + expect(response.body).to include("< 1 KB") + expect(response.body).to include("> 1 MB") + end + + it "links largest entries to their detail pages" do + SolidCache::Entry.write("linked:key", "value") + get "#{engine_root}/cache" + expect(response.body).to include("linked:key") + expect(response.body).to include("cache/entries") + end end end From a6327eff2403936eaf1dff1d76d3c4c9b6eff91f Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 10:02:35 -0400 Subject: [PATCH 2/2] docs: update CHANGELOG, README, and ROADMAP for cache size distribution Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 2 ++ ROADMAP.md | 1 - 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af10555..6c81a83 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 size distribution stats — Solid Cache overview page gains a **Size Distribution** table (5 byte-range buckets with proportional inline bars) and a **Largest Entries** table (top 10 by byte size, each linked to its detail page); both sections are hidden when the cache is empty - Cache stats dashboard card — adds **Oldest** entry age (via `time_ago_in_words` with exact timestamp on hover) to the Solid Cache overview card; stat is hidden when the cache is empty; **Entries** count is now a link to the entry browser; `oldest_entry` added to the `/metrics` JSON payload as ISO 8601 (or `null`) - 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 with JSON pretty-printing and a format badge (JSON / Text); 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 diff --git a/README.md b/README.md index 826c615..f61ee3a 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru ### Features - **Overview dashboard card** — live entry count (linked to the entry browser), total byte size, and oldest-entry age (`time_ago_in_words` with exact timestamp on hover; hidden when cache is empty) +- **Size distribution** — byte-range histogram (< 1 KB → > 1 MB) with entry counts and proportional inline bars; hidden when cache is empty +- **Largest entries** — top 10 entries by byte size, each linked to the detail page; hidden when cache is empty - **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) diff --git a/ROADMAP.md b/ROADMAP.md index 73a5325..37fec7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,6 @@ 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 -- **Size distribution stats** — top-N entries by byte size; histogram bucketed by size range - **Cache timeline** — 24-hour chart of entry count and total byte size growth ---