From 06660271bc5756eff19891a716b9b810ebd32c41 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 16:09:11 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20cable=20timeline=20=E2=80=94=2024-h?= =?UTF-8?q?our=20message=20volume=20chart=20on=20Cable=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 1 + ROADMAP.md | 3 -- .../solid_stack_web/_07_dashboard.css | 8 +++++ .../solid_stack_web/cable_controller.rb | 1 + .../solid_stack_web/application_helper.rb | 12 +++++++ app/models/solid_stack_web/cable_timeline.rb | 32 +++++++++++++++++++ .../solid_stack_web/cable/index.html.erb | 15 +++++++++ 8 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 app/models/solid_stack_web/cable_timeline.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b8dd8de..0473d31 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 +- Cable timeline — 24-hour bar chart of message volume on the Solid Cable overview page; each bar represents one hour with a tooltip showing exact count; uses the `var(--info)` color token - Cable stats dashboard card — the Solid Cable card on the overview dashboard now shows messages per hour (last 60 minutes), oldest pending message age, and a top-3 channel breakdown by message volume; Messages stat is now a link to the Cable overview - Message purge — "Purge Old" form on the channel browser deletes all messages older than 1, 7, or 30 days; "Purge Channel" button on the per-channel message list deletes all messages for that channel; both redirect to the channel browser after purging - Message search — channel browser (`GET /cable`) accepts `?q=` to filter the channel list by name substring; per-channel message list (`GET /cable/channels/:channel_hash`) accepts `?q=` to filter messages by payload substring; both show a contextual empty state and a Clear link when a search is active diff --git a/README.md b/README.md index aab213f..977724b 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru ### Features - **Dashboard card** — the overview dashboard shows total messages, channels, messages per hour (last 60 minutes), oldest pending message age, and a top-3 channel breakdown by volume +- **24-hour timeline** — bar chart of message volume on the Cable overview page; each bar represents one hour with a hover tooltip showing the exact count - **Channel browser** — `GET /cable` lists all active channels with per-channel message count and last-message timestamp, ordered by most recent activity; supports `?q=` filtering by channel name substring; empty state shown when no messages exist - **Per-channel message list** — `GET /cable/channels/:channel_hash` shows a paginated, reverse-chronological list of that channel's `SolidCable::Message` records; each row shows the message ID, a truncated payload preview (120 chars) with the full payload on hover, and a relative sent time with the exact timestamp on hover; supports `?q=` filtering by payload substring; **Purge Channel** button deletes all messages for the channel - **Message purge** — "Purge Old" form on the channel browser deletes all messages older than 1, 7, or 30 days; confirmation prompt before any destructive action diff --git a/ROADMAP.md b/ROADMAP.md index a7c3493..5414ad4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,9 +10,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das > _Surface what's actually flowing through Action Cable._ -### Added -- **Cable timeline** — 24-hour chart of message volume - --- ## v0.7.0 — Interactivity & UX diff --git a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css index 7d3c072..bcce610 100644 --- a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +++ b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css @@ -121,11 +121,19 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; } overflow: hidden; } +.sqw-timeline-grid--single { + grid-template-columns: 1fr; +} + .sqw-timeline-chart { border-top: none; color: var(--purple); } +.sqw-timeline-chart--cable { + color: var(--info); +} + .sqw-timeline-chart + .sqw-timeline-chart { border-left: 1px solid var(--border); } diff --git a/app/controllers/solid_stack_web/cable_controller.rb b/app/controllers/solid_stack_web/cable_controller.rb index aefff80..6744dfb 100644 --- a/app/controllers/solid_stack_web/cable_controller.rb +++ b/app/controllers/solid_stack_web/cable_controller.rb @@ -4,6 +4,7 @@ def index @search = params[:q].presence @total_messages = ::SolidCable::Message.count @channels = channel_rows + @timeline = CableTimeline.new end private diff --git a/app/helpers/solid_stack_web/application_helper.rb b/app/helpers/solid_stack_web/application_helper.rb index 8cc9ad0..5d5d401 100644 --- a/app/helpers/solid_stack_web/application_helper.rb +++ b/app/helpers/solid_stack_web/application_helper.rb @@ -42,6 +42,18 @@ def cache_bytes_timeline_svg(timeline) end end + def cable_messages_timeline_svg(timeline) + build_sparkline_svg( + Struct.new(:buckets, :max).new(timeline.message_buckets, timeline.message_max), + css_class: "sqw-sparkline sqw-sparkline--lg", + aria_label: "Cable messages over the last 24 hours" + ) do |count, i| + hours_ago = CableTimeline::HOURS - 1 - i + label = count == 1 ? "message" : "messages" + hours_ago.zero? ? "#{count} #{label} this hour" : "#{count} #{label} #{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/cable_timeline.rb b/app/models/solid_stack_web/cable_timeline.rb new file mode 100644 index 0000000..e151d2a --- /dev/null +++ b/app/models/solid_stack_web/cable_timeline.rb @@ -0,0 +1,32 @@ +module SolidStackWeb + class CableTimeline + HOURS = 24 + + def message_buckets + @message_buckets ||= build_buckets { |rows, from, to| rows.count { |t| t >= from && t < to } } + end + + def message_max + message_buckets.max || 0 + end + + private + + def rows + @rows ||= begin + origin = Time.current - HOURS.hours + ::SolidCable::Message.where(created_at: origin..Time.current).pluck(:created_at) + 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/cable/index.html.erb b/app/views/solid_stack_web/cable/index.html.erb index 5e9e1e6..552b21d 100644 --- a/app/views/solid_stack_web/cable/index.html.erb +++ b/app/views/solid_stack_web/cable/index.html.erb @@ -22,6 +22,21 @@ +
+
+ Messages — last 24 hours +
+ <%= cable_messages_timeline_svg(@timeline) %> + +
+
+ 24h ago + 12h ago + now +
+
+
+
Date: Tue, 26 May 2026 16:09:53 -0400 Subject: [PATCH 2/2] chore: add margin-bottom to timeline grid to match margin-top Co-Authored-By: Claude Sonnet 4.6 --- app/assets/stylesheets/solid_stack_web/_07_dashboard.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css index bcce610..f37fa29 100644 --- a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +++ b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css @@ -114,6 +114,7 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; } grid-template-columns: 1fr 1fr; gap: 0; margin-top: 1.5rem; + margin-bottom: 1.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);