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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
27 changes: 27 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_03_stats.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
1 change: 1 addition & 0 deletions app/controllers/solid_stack_web/cache_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 33 additions & 0 deletions app/models/solid_stack_web/cache_size_stats.rb
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions app/views/solid_stack_web/cache/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,55 @@
<span class="sqw-stat__value"><%= number_to_human_size(@total_byte_size) %></span>
</div>
</div>

<% if @total_entries > 0 %>
<div class="sqw-size-grid">
<section class="sqw-size-section">
<h2 class="sqw-section-title">Size Distribution</h2>
<table class="sqw-table">
<thead>
<tr>
<th>Range</th>
<th>Entries</th>
<th></th>
</tr>
</thead>
<tbody>
<% @size_stats.buckets.each do |bucket| %>
<% pct = @size_stats.total > 0 ? (bucket[:count].to_f / @size_stats.total * 100).round(1) : 0 %>
<tr>
<td class="sqw-muted"><%=bucket[:label] %></td>
<td><%= bucket[:count] %></td>
<td class="sqw-dist-bar-cell">
<div class="sqw-dist-bar" style="width: <%= pct %>%"></div>
<span class="sqw-dist-pct sqw-muted"><%= pct %>%</span>
</td>
</tr>
<% end %>
</tbody>
</table>
</section>

<section class="sqw-size-section">
<h2 class="sqw-section-title">Largest Entries</h2>
<table class="sqw-table">
<thead>
<tr>
<th>Key</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<% @size_stats.top_entries.each do |entry| %>
<tr>
<td class="sqw-monospace sqw-truncate" title="<%= entry.key %>">
<%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
</td>
<td><%= number_to_human_size(entry.byte_size) %></td>
</tr>
<% end %>
</tbody>
</table>
</section>
</div>
<% end %>
29 changes: 29 additions & 0 deletions spec/requests/solid_stack_web/cache_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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("&lt; 1 KB")
expect(response.body).to include("&gt; 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