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

- 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
- 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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru

### 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
- **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

---

Expand Down
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- **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
- **Cable timeline** — 24-hour chart of message volume
Expand Down
7 changes: 6 additions & 1 deletion app/controllers/solid_stack_web/cable_controller.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
module SolidStackWeb
class CableController < ApplicationController
def index
@search = params[:q].presence
@total_messages = ::SolidCable::Message.count
@channels = channel_rows
end

private

def channel_rows
::SolidCable::Message
scope = ::SolidCable::Message
if @search
scope = scope.where("channel LIKE ?", "%#{::ActiveRecord::Base.sanitize_sql_like(@search)}%")
end
scope
.group(:channel, :channel_hash)
.order(Arel.sql("MAX(created_at) DESC"))
.pluck(:channel, :channel_hash, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)"))
Expand Down
10 changes: 7 additions & 3 deletions app/controllers/solid_stack_web/cable_messages_controller.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
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)
@search = params[:q].presence
scope = ::SolidCable::Message.where(channel_hash: params[:channel_hash])
if @search
scope = scope.where("payload LIKE ?", "%#{::ActiveRecord::Base.sanitize_sql_like(@search)}%")
end
@channel_name = ::SolidCable::Message.where(channel_hash: params[:channel_hash]).pick(:channel) || params[:channel_hash]
@pagy, @messages = pagy(scope.order(created_at: :desc))
end
end
end
11 changes: 10 additions & 1 deletion app/views/solid_stack_web/cable/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
</div>
</div>

<form class="sqw-filters" action="<%= cable_path %>" method="get" data-controller="search">
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by channel…" autocomplete="off" aria-label="Filter by channel"
data-action="input->search#filter">
<% if @search.present? %>
<%= link_to "Clear", cable_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
<% end %>
</form>

<% if @channels.any? %>
<table class="sqw-table">
<thead>
Expand All @@ -36,6 +45,6 @@
</table>
<% else %>
<div class="sqw-empty">
<p>No cable messages.</p>
<p><%= @search.present? ? "No channels matching &ldquo;#{@search}&rdquo;.".html_safe : "No cable messages." %></p>
</div>
<% end %>
11 changes: 10 additions & 1 deletion app/views/solid_stack_web/cable_messages/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
</div>
</div>

<form class="sqw-filters" action="<%= cable_channel_messages_path(params[:channel_hash]) %>" method="get" data-controller="search">
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by payload…" autocomplete="off" aria-label="Filter by payload"
data-action="input->search#filter">
<% if @search.present? %>
<%= link_to "Clear", cable_channel_messages_path(params[:channel_hash]), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
<% end %>
</form>

<% if @messages.any? %>
<table class="sqw-table">
<thead>
Expand All @@ -31,6 +40,6 @@
<%== @pagy.series_nav if @pagy.pages > 1 %>
<% else %>
<div class="sqw-empty">
<p>No messages for this channel.</p>
<p><%= @search.present? ? "No messages matching &ldquo;#{@search}&rdquo;.".html_safe : "No messages for this channel." %></p>
</div>
<% end %>
29 changes: 29 additions & 0 deletions spec/requests/solid_stack_web/cable_messages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,34 @@ def channel_hash_for(channel)
expect(response).to have_http_status(:ok)
expect(response.body).to include("No messages for this channel")
end

it "filters messages by payload substring" do
broadcast("events", "user_signed_in")
broadcast("events", "order_placed")
hash = channel_hash_for("events")

get "#{engine_root}/cable/channels/#{hash}", params: { q: "signed" }

expect(response.body).to include("user_signed_in")
expect(response.body).not_to include("order_placed")
end

it "shows contextual empty state when payload search yields no results" do
broadcast("events", "something")
hash = channel_hash_for("events")

get "#{engine_root}/cable/channels/#{hash}", params: { q: "nope" }

expect(response.body).to include("No messages matching")
end

it "shows a clear link when a payload search is active" do
broadcast("events", "ping")
hash = channel_hash_for("events")

get "#{engine_root}/cable/channels/#{hash}", params: { q: "ping" }

expect(response.body).to include("Clear")
end
end
end
26 changes: 26 additions & 0 deletions spec/requests/solid_stack_web/cable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,31 @@
get "#{engine_root}/cable"
expect(response.body).to include("No cable messages")
end

it "filters channels by name substring" do
SolidCable::Message.broadcast("sports:scores", "goal")
SolidCable::Message.broadcast("chat:general", "hello")

get "#{engine_root}/cable", params: { q: "sports" }

expect(response.body).to include("sports:scores")
expect(response.body).not_to include("chat:general")
end

it "shows contextual empty state when search yields no results" do
SolidCable::Message.broadcast("chat", "hi")

get "#{engine_root}/cable", params: { q: "nope" }

expect(response.body).to include("No channels matching")
end

it "shows a clear link when a search is active" do
SolidCable::Message.broadcast("chat", "hi")

get "#{engine_root}/cable", params: { q: "chat" }

expect(response.body).to include("Clear")
end
end
end