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..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
-_Channel monitoring coming in v0.6.0. Currently shows active message count and distinct channel count on the overview dashboard._
+### 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 5027f33..44f9036 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,8 +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
- **Stats dashboard card** — expand the overview card to include messages per hour, oldest pending message age, and top channels by volume
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 @@
+
+
+<% if @messages.any? %>
+
+
+
+ | ID |
+ Payload |
+ Sent |
+
+
+
+ <% @messages.each do |message| %>
+
+ | <%= message.id %> |
+
+ <%= truncate(message.payload.to_s, length: 120) %>
+ |
+ ">
+ <%= time_ago_in_words(message.created_at) %> ago
+ |
+
+ <% end %>
+
+
+ <%== @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
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")
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