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
---
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
+
+
+
+ | Range |
+ Entries |
+ |
+
+
+
+ <% @size_stats.buckets.each do |bucket| %>
+ <% pct = @size_stats.total > 0 ? (bucket[:count].to_f / @size_stats.total * 100).round(1) : 0 %>
+
+ | <%=bucket[:label] %> |
+ <%= bucket[:count] %> |
+
+
+ <%= pct %>%
+ |
+
+ <% end %>
+
+
+
+
+
+ Largest Entries
+
+
+
+ | Key |
+ Size |
+
+
+
+ <% @size_stats.top_entries.each do |entry| %>
+
+ |
+ <%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
+ |
+ <%= number_to_human_size(entry.byte_size) %> |
+
+ <% end %>
+
+
+
+
+<% 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