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

- 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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down
2 changes: 0 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions app/controllers/solid_stack_web/cable_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/controllers/solid_stack_web/cable_messages_controller.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion app/views/solid_stack_web/cable/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
<tbody>
<% @channels.each do |channel| %>
<tr>
<td class="sqw-monospace"><%= channel[:channel] %></td>
<td class="sqw-monospace sqw-truncate" title="<%= channel[:channel] %>">
<%= link_to channel[:channel], cable_channel_messages_path(channel[:channel_hash]), class: "sqw-link" %>
</td>
<td><%= channel[:message_count] %></td>
<td class="sqw-muted"><%= channel[:last_message_at]&.strftime("%b %d %H:%M") %></td>
</tr>
Expand Down
36 changes: 36 additions & 0 deletions app/views/solid_stack_web/cable_messages/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<div class="sqw-page-header sqw-page-header--split">
<h1 class="sqw-page-title sqw-truncate" title="<%= @channel_name %>"><%= @channel_name %></h1>
<div class="sqw-header-actions">
<%= link_to "← Channels", cable_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
</div>
</div>

<% if @messages.any? %>
<table class="sqw-table">
<thead>
<tr>
<th>ID</th>
<th>Payload</th>
<th>Sent</th>
</tr>
</thead>
<tbody>
<% @messages.each do |message| %>
<tr>
<td class="sqw-muted sqw-monospace"><%= message.id %></td>
<td class="sqw-monospace sqw-truncate" title="<%= message.payload %>">
<%= truncate(message.payload.to_s, length: 120) %>
</td>
<td class="sqw-muted" title="<%= message.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %>">
<%= time_ago_in_words(message.created_at) %> ago
</td>
</tr>
<% end %>
</tbody>
</table>
<%== @pagy.series_nav if @pagy.pages > 1 %>
<% else %>
<div class="sqw-empty">
<p>No messages for this channel.</p>
</div>
<% end %>
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions spec/helpers/solid_stack_web/application_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
89 changes: 89 additions & 0 deletions spec/requests/solid_stack_web/cable_messages_spec.rb
Original file line number Diff line number Diff line change
@@ -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