From 5717442245804114cd97df7bbc24a492797b016b Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 12:01:07 -0400 Subject: [PATCH 1/4] feat: per-channel message list for Solid Cable GET /cable/channels/:channel_hash shows a paginated reverse-chronological list of SolidCable::Message records for a specific channel, with payload preview (120 chars) and relative sent time. Channel names in the channel browser are now links using channel_hash as the URL-safe identifier. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 2 +- ROADMAP.md | 1 - .../solid_stack_web/cable_controller.rb | 8 ++--- .../cable_messages_controller.rb | 9 +++++ .../solid_stack_web/cable/index.html.erb | 4 ++- .../cable_messages/index.html.erb | 36 +++++++++++++++++++ config/routes.rb | 1 + 8 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 app/controllers/solid_stack_web/cable_messages_controller.rb create mode 100644 app/views/solid_stack_web/cable_messages/index.html.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 441b17b..51f592d 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 +- Per-channel message list — `GET /cable/channels/:channel_hash` shows a paginated, reverse-chronological list of `SolidCable::Message` records for a specific channel; each row shows the message ID, a truncated payload preview (120 chars), and relative sent time with exact timestamp on hover; channel names in the channel browser are now links to their message list - Solid Cable channel browser — `GET /cable` lists all distinct channels with per-channel message count and last-message timestamp, ordered by most recent activity; a total-messages stat and channel-count stat are shown at the top; empty state shown when no messages exist; Cable subnav (Overview) added to the layout ## [0.5.0] - 2026-05-26 diff --git a/README.md b/README.md index 781ef60..949f2b5 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru ## Solid Cable -_Channel monitoring coming in v0.6.0. Currently shows active message count and distinct channel count on the overview dashboard._ +`GET /cable` lists all active channels with per-channel message count and last-message timestamp, ordered by most recent activity. Clicking a channel opens `GET /cable/channels/:channel_hash` — a paginated, reverse-chronological list of that channel's messages with payload preview and relative timestamps. --- diff --git a/ROADMAP.md b/ROADMAP.md index 5027f33..95e9ac3 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 > _Surface what's actually flowing through Action Cable._ ### Added -- **Channel browser** — list all active channels with message count, last message time, and subscriber count (where available) - **Per-channel message list** — paginated view of recent `SolidCable::Message` records for a channel, with payload preview - **Message search** — filter messages across channels by channel name or payload substring - **Message purge** — delete all messages for a channel or all messages older than N days diff --git a/app/controllers/solid_stack_web/cable_controller.rb b/app/controllers/solid_stack_web/cable_controller.rb index bbda29a..59308a5 100644 --- a/app/controllers/solid_stack_web/cable_controller.rb +++ b/app/controllers/solid_stack_web/cable_controller.rb @@ -9,12 +9,12 @@ def index def channel_rows ::SolidCable::Message - .group(:channel) + .group(:channel, :channel_hash) .order(Arel.sql("MAX(created_at) DESC")) - .pluck(:channel, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)")) - .map do |ch, cnt, last_at| + .pluck(:channel, :channel_hash, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)")) + .map do |ch, hash, cnt, last_at| last_at = Time.zone.parse(last_at) if last_at.is_a?(String) - { channel: ch, message_count: cnt, last_message_at: last_at } + { channel: ch, channel_hash: hash, message_count: cnt, last_message_at: last_at } end end end diff --git a/app/controllers/solid_stack_web/cable_messages_controller.rb b/app/controllers/solid_stack_web/cable_messages_controller.rb new file mode 100644 index 0000000..36e28d5 --- /dev/null +++ b/app/controllers/solid_stack_web/cable_messages_controller.rb @@ -0,0 +1,9 @@ +module SolidStackWeb + class CableMessagesController < ApplicationController + def index + scope = ::SolidCable::Message.where(channel_hash: params[:channel_hash]).order(created_at: :desc) + @channel_name = scope.pick(:channel) || params[:channel_hash] + @pagy, @messages = pagy(scope) + 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 86e1103..3d35578 100644 --- a/app/views/solid_stack_web/cable/index.html.erb +++ b/app/views/solid_stack_web/cable/index.html.erb @@ -25,7 +25,9 @@ <% @channels.each do |channel| %> - <%= channel[:channel] %> + + <%= link_to channel[:channel], cable_channel_messages_path(channel[:channel_hash]), class: "sqw-link" %> + <%= channel[:message_count] %> <%= channel[:last_message_at]&.strftime("%b %d %H:%M") %> diff --git a/app/views/solid_stack_web/cable_messages/index.html.erb b/app/views/solid_stack_web/cable_messages/index.html.erb new file mode 100644 index 0000000..7504c59 --- /dev/null +++ b/app/views/solid_stack_web/cable_messages/index.html.erb @@ -0,0 +1,36 @@ +
+

<%= @channel_name %>

+
+ <%= link_to "← Channels", cable_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> +
+
+ +<% if @messages.any? %> + + + + + + + + + + <% @messages.each do |message| %> + + + + + + <% end %> + +
IDPayloadSent
<%= message.id %> + <%= truncate(message.payload.to_s, length: 120) %> + "> + <%= time_ago_in_words(message.created_at) %> ago +
+ <%== @pagy.series_nav if @pagy.pages > 1 %> +<% else %> +
+

No messages for this channel.

+
+<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 6106fc5..c21ac9c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,4 +38,5 @@ resources :cache_entries, only: [:index, :show, :destroy], path: "cache/entries" resource :cache_flush, only: [:destroy], path: "cache/flush", controller: "cache/flushes" get "cable", to: "cable#index", as: :cable + get "cable/channels/:channel_hash", to: "cable_messages#index", as: :cable_channel_messages end From bcdd7ce81bed9e599e99abaa2e864bec08e64a72 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 12:14:34 -0400 Subject: [PATCH 2/4] test: request specs for CableMessagesController Covers index action: 200 response, channel isolation, newest-first ordering, channel name as page title, payload truncation, back link, and empty state for unknown channel_hash. Co-Authored-By: Claude Sonnet 4.6 --- .../solid_stack_web/cable_messages_spec.rb | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 spec/requests/solid_stack_web/cable_messages_spec.rb diff --git a/spec/requests/solid_stack_web/cable_messages_spec.rb b/spec/requests/solid_stack_web/cable_messages_spec.rb new file mode 100644 index 0000000..c9818bf --- /dev/null +++ b/spec/requests/solid_stack_web/cable_messages_spec.rb @@ -0,0 +1,89 @@ +require "rails_helper" + +RSpec.describe "CableMessages", type: :request do + let(:engine_root) { "/solid_stack" } + + def broadcast(channel, payload = "test payload") + SolidCable::Message.broadcast(channel, payload) + SolidCable::Message.where(channel: channel).order(created_at: :desc).first + end + + def channel_hash_for(channel) + SolidCable::Message.where(channel: channel).pick(:channel_hash) + end + + describe "GET /cable/channels/:channel_hash" do + it "returns 200" do + msg = broadcast("sports:scores", "goal!") + get "#{engine_root}/cable/channels/#{msg.channel_hash}" + expect(response).to have_http_status(:ok) + end + + it "shows messages belonging to the channel" do + broadcast("chat", "hello") + broadcast("chat", "world") + hash = channel_hash_for("chat") + + get "#{engine_root}/cable/channels/#{hash}" + + expect(response.body).to include("hello") + expect(response.body).to include("world") + end + + it "does not show messages from other channels" do + broadcast("chat", "mine") + broadcast("other", "not mine") + hash = channel_hash_for("chat") + + get "#{engine_root}/cable/channels/#{hash}" + + expect(response.body).to include("mine") + expect(response.body).not_to include("not mine") + end + + it "orders messages newest first" do + broadcast("feed", "first message") + travel 1.second do + broadcast("feed", "second message") + end + hash = channel_hash_for("feed") + + get "#{engine_root}/cable/channels/#{hash}" + + expect(response.body.index("second message")).to be < response.body.index("first message") + end + + it "shows the channel name as the page title" do + broadcast("notifications:user:42", "ping") + hash = channel_hash_for("notifications:user:42") + + get "#{engine_root}/cable/channels/#{hash}" + + expect(response.body).to include("notifications:user:42") + end + + it "truncates long payloads in the preview" do + broadcast("log", "x" * 200) + hash = channel_hash_for("log") + + get "#{engine_root}/cable/channels/#{hash}" + + expect(response.body).to include("#{"x" * 117}...") + end + + it "includes a back link to the channel browser" do + broadcast("chat", "msg") + hash = channel_hash_for("chat") + + get "#{engine_root}/cable/channels/#{hash}" + + expect(response.body).to include("← Channels") + end + + it "shows empty state for an unknown channel_hash" do + get "#{engine_root}/cable/channels/nonexistent000" + expect(response).to have_http_status(:ok) + expect(response.body).to include("No messages for this channel") + end + end +end From ae8fb497b1e5474db68da3507646bcaf5c68ee20 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 12:16:57 -0400 Subject: [PATCH 3/4] test: add format_cache_value coverage to ApplicationHelper spec Covers the JSON happy path (label + pretty-print), the plain-text fallback, and invalid-byte replacement. Co-Authored-By: Claude Sonnet 4.6 --- .../application_helper_spec.rb | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/spec/helpers/solid_stack_web/application_helper_spec.rb b/spec/helpers/solid_stack_web/application_helper_spec.rb index cfcf60e..5292325 100644 --- a/spec/helpers/solid_stack_web/application_helper_spec.rb +++ b/spec/helpers/solid_stack_web/application_helper_spec.rb @@ -1,6 +1,26 @@ require "rails_helper" RSpec.describe SolidStackWeb::ApplicationHelper, type: :helper do + describe "#format_cache_value" do + it "returns JSON label and pretty-printed content for valid JSON input" do + result = helper.format_cache_value('{"key":"value"}') + expect(result[:label]).to eq("JSON") + expect(result[:content]).to include('"key": "value"') + end + + it "returns Text label and raw content for non-JSON input" do + result = helper.format_cache_value("plain string") + expect(result[:label]).to eq("Text") + expect(result[:content]).to eq("plain string") + end + + it "replaces invalid bytes and returns Text label" do + result = helper.format_cache_value("\xFF\xFE") + expect(result[:label]).to eq("Text") + expect(result[:content]).to include("?") + end + end + describe "#format_duration" do it "returns seconds for values under 60" do expect(helper.format_duration(45)).to eq("45s") From 62ae75e3b5c9d4f4091181609c59ce97554751e1 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 12:20:03 -0400 Subject: [PATCH 4/4] docs: update README and ROADMAP for per-channel message list README Solid Cable section gains a proper ### Features list matching the Solid Queue and Solid Cache sections. ROADMAP removes the now-shipped per-channel message list item from v0.6.0. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 5 ++++- ROADMAP.md | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 949f2b5..7c23216 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,10 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru ## Solid Cable -`GET /cable` lists all active channels with per-channel message count and last-message timestamp, ordered by most recent activity. Clicking a channel opens `GET /cable/channels/:channel_hash` — a paginated, reverse-chronological list of that channel's messages with payload preview and relative timestamps. +### Features + +- **Channel browser** — `GET /cable` lists all active channels with per-channel message count and last-message timestamp, ordered by most recent activity; 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 --- diff --git a/ROADMAP.md b/ROADMAP.md index 95e9ac3..44f9036 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 > _Surface what's actually flowing through Action Cable._ ### Added -- **Per-channel message list** — paginated view of recent `SolidCable::Message` records for a channel, with payload preview - **Message search** — filter messages across channels by channel name or payload substring - **Message purge** — delete all messages for a channel or all messages older than N days - **Stats dashboard card** — expand the overview card to include messages per hour, oldest pending message age, and top channels by volume