From d1035bc66a1b2437627a2051853044b361fdab58 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 10:07:51 -0400 Subject: [PATCH 1/3] feat: 24-hour cache timeline charts (#28) Adds two side-by-side bar charts to the Solid Cache overview page: - Entries written per hour over the last 24 hours - Bytes written per hour over the last 24 hours Backed by CacheTimeline PORO (single query, bucketed in Ruby). Reuses the existing sparkline SVG helper and sparkline-tooltip Stimulus controller; bars use a new sqw-sparkline--lg (64px) modifier. Closes #28 Co-Authored-By: Claude Sonnet 4.6 --- .../solid_stack_web/_07_dashboard.css | 24 ++++++++++- .../solid_stack_web/cache_controller.rb | 1 + .../solid_stack_web/application_helper.rb | 24 +++++++++++ app/models/solid_stack_web/cache_timeline.rb | 40 +++++++++++++++++++ .../solid_stack_web/cache/index.html.erb | 30 +++++++++++++- spec/requests/solid_stack_web/cache_spec.rb | 12 ++++++ 6 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 app/models/solid_stack_web/cache_timeline.rb diff --git a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css index d561608..a55e631 100644 --- a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +++ b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css @@ -106,8 +106,28 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; } height: 40px; } -.sqw-sparkline--sm { - height: 24px; +.sqw-sparkline--sm { height: 24px; } +.sqw-sparkline--lg { height: 64px; } + +.sqw-timeline-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + margin-top: 1.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + overflow: hidden; +} + +.sqw-timeline-chart { + border-top: none; + color: var(--purple); +} + +.sqw-timeline-chart + .sqw-timeline-chart { + border-left: 1px solid var(--border); } .sqw-queue-sparkline { diff --git a/app/controllers/solid_stack_web/cache_controller.rb b/app/controllers/solid_stack_web/cache_controller.rb index 4ca6933..4db233e 100644 --- a/app/controllers/solid_stack_web/cache_controller.rb +++ b/app/controllers/solid_stack_web/cache_controller.rb @@ -4,6 +4,7 @@ def index @total_entries = ::SolidCache::Entry.count @total_byte_size = ::SolidCache::Entry.sum(:byte_size) @size_stats = CacheSizeStats.new + @timeline = CacheTimeline.new end end end diff --git a/app/helpers/solid_stack_web/application_helper.rb b/app/helpers/solid_stack_web/application_helper.rb index 73adc1d..8cc9ad0 100644 --- a/app/helpers/solid_stack_web/application_helper.rb +++ b/app/helpers/solid_stack_web/application_helper.rb @@ -18,6 +18,30 @@ def format_duration(seconds) "#{s / 3600}h #{(s % 3600) / 60}m" end + def cache_entries_timeline_svg(timeline) + build_sparkline_svg( + Struct.new(:buckets, :max).new(timeline.entry_buckets, timeline.entry_max), + css_class: "sqw-sparkline sqw-sparkline--lg", + aria_label: "Cache entries written over the last 24 hours" + ) do |count, i| + hours_ago = CacheTimeline::HOURS - 1 - i + hours_ago.zero? ? "#{count} #{"entry".then { |w| count == 1 ? w : "entries" }} this hour" \ + : "#{count} #{"entry".then { |w| count == 1 ? w : "entries" }} #{hours_ago}h ago" + end + end + + def cache_bytes_timeline_svg(timeline) + build_sparkline_svg( + Struct.new(:buckets, :max).new(timeline.byte_buckets, timeline.byte_max), + css_class: "sqw-sparkline sqw-sparkline--lg", + aria_label: "Cache bytes written over the last 24 hours" + ) do |bytes, i| + hours_ago = CacheTimeline::HOURS - 1 - i + size = number_to_human_size(bytes) + hours_ago.zero? ? "#{size} written this hour" : "#{size} written #{hours_ago}h ago" + end + end + def throughput_sparkline_svg(sparkline) build_sparkline_svg(sparkline, aria_label: "Throughput over the last 12 hours") do |count, i| hours_ago = SolidStackWeb::ThroughputSparkline::HOURS - i diff --git a/app/models/solid_stack_web/cache_timeline.rb b/app/models/solid_stack_web/cache_timeline.rb new file mode 100644 index 0000000..cfa75a4 --- /dev/null +++ b/app/models/solid_stack_web/cache_timeline.rb @@ -0,0 +1,40 @@ +module SolidStackWeb + class CacheTimeline + HOURS = 24 + + def entry_buckets + @entry_buckets ||= build_buckets { |rows, from, to| rows.count { |t, _| t >= from && t < to } } + end + + def byte_buckets + @byte_buckets ||= build_buckets { |rows, from, to| rows.sum { |t, b| t >= from && t < to ? b : 0 } } + end + + def entry_max + entry_buckets.max || 0 + end + + def byte_max + byte_buckets.max || 0 + end + + private + + def rows + @rows ||= begin + origin = Time.current - HOURS.hours + ::SolidCache::Entry.where(created_at: origin..Time.current).pluck(:created_at, :byte_size) + end + end + + def build_buckets(&block) + now = Time.current + origin = now - HOURS.hours + HOURS.times.map do |i| + from = origin + i.hours + to = origin + (i + 1).hours + block.call(rows, from, to) + end + 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 2c68522..80ead13 100644 --- a/app/views/solid_stack_web/cache/index.html.erb +++ b/app/views/solid_stack_web/cache/index.html.erb @@ -63,4 +63,32 @@ -<% end %> \ No newline at end of file +<% end %> + +
+
+ Entries written — last 24 hours +
+ <%= cache_entries_timeline_svg(@timeline) %> + +
+
+ 24h ago + 12h ago + now +
+
+ +
+ Bytes written — last 24 hours +
+ <%= cache_bytes_timeline_svg(@timeline) %> + +
+
+ 24h ago + 12h ago + now +
+
+
\ 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 8d0124a..6799bd7 100644 --- a/spec/requests/solid_stack_web/cache_spec.rb +++ b/spec/requests/solid_stack_web/cache_spec.rb @@ -60,5 +60,17 @@ expect(response.body).to include("linked:key") expect(response.body).to include("cache/entries") end + + it "renders 24-hour timeline charts" do + get "#{engine_root}/cache" + expect(response.body).to include("Entries written — last 24 hours") + expect(response.body).to include("Bytes written — last 24 hours") + end + + it "shows timeline bars for entries written in the last 24 hours" do + SolidCache::Entry.write("recent:key", "v") + get "#{engine_root}/cache" + expect(response.body).to include("sqw-sparkline--lg") + end end end From 3c1d23879009938eb85d70e8876e9713e867841f Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 10:09:17 -0400 Subject: [PATCH 2/3] docs: update CHANGELOG, README, and ROADMAP for cache timeline v0.5.0 milestone fully shipped; section removed from ROADMAP. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 1 + ROADMAP.md | 9 --------- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c81a83..e06c1fd 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 timeline — Solid Cache overview page gains two side-by-side 24-hour bar charts: **Entries written per hour** and **Bytes written per hour**; each bar has a hover tooltip via the `sparkline-tooltip` Stimulus controller; backed by a `CacheTimeline` PORO (single query, bucketed in Ruby) - 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 diff --git a/README.md b/README.md index f61ee3a..781ef60 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ 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) +- **Cache timeline** — two side-by-side 24-hour bar charts showing entries written per hour and bytes written per hour; each bar has a hover tooltip - **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 diff --git a/ROADMAP.md b/ROADMAP.md index 37fec7d..5027f33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,15 +6,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das --- -## v0.5.0 — Solid Cache: Deep Monitoring - -> _Move beyond a single count; give operators visibility into what's in the cache._ - -### Added -- **Cache timeline** — 24-hour chart of entry count and total byte size growth - ---- - ## v0.6.0 — Solid Cable: Channel Monitoring > _Surface what's actually flowing through Action Cable._ From c9508eaa2f030d548711b0d94772d22212a5d1a8 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 10:11:39 -0400 Subject: [PATCH 3/3] fix: wrap dist bar in div so td stays a proper table cell display:flex on a td breaks row-height alignment with adjacent cells. Moving the flex wrapper to an inner div fixes the vertical misalignment. Co-Authored-By: Claude Sonnet 4.6 --- app/views/solid_stack_web/cache/index.html.erb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/solid_stack_web/cache/index.html.erb b/app/views/solid_stack_web/cache/index.html.erb index 80ead13..862bc76 100644 --- a/app/views/solid_stack_web/cache/index.html.erb +++ b/app/views/solid_stack_web/cache/index.html.erb @@ -31,9 +31,11 @@ <%=bucket[:label] %> <%= bucket[:count] %> - -
- <%= pct %>% + +
+
+ <%= pct %>% +
<% end %>