diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f592d..9f55dd7 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 +- 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 diff --git a/README.md b/README.md index 7c23216..60334bc 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/ROADMAP.md b/ROADMAP.md index 44f9036..27fb30d 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 -- **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 diff --git a/app/controllers/solid_stack_web/cable_controller.rb b/app/controllers/solid_stack_web/cable_controller.rb index 59308a5..aefff80 100644 --- a/app/controllers/solid_stack_web/cable_controller.rb +++ b/app/controllers/solid_stack_web/cable_controller.rb @@ -1,6 +1,7 @@ module SolidStackWeb class CableController < ApplicationController def index + @search = params[:q].presence @total_messages = ::SolidCable::Message.count @channels = channel_rows end @@ -8,7 +9,11 @@ def index 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)")) diff --git a/app/controllers/solid_stack_web/cable_messages_controller.rb b/app/controllers/solid_stack_web/cable_messages_controller.rb index 36e28d5..fc6ef5e 100644 --- a/app/controllers/solid_stack_web/cable_messages_controller.rb +++ b/app/controllers/solid_stack_web/cable_messages_controller.rb @@ -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 diff --git a/app/views/solid_stack_web/cable/index.html.erb b/app/views/solid_stack_web/cable/index.html.erb index 3d35578..4bd9d43 100644 --- a/app/views/solid_stack_web/cable/index.html.erb +++ b/app/views/solid_stack_web/cable/index.html.erb @@ -13,6 +13,15 @@ +
+ + <% if @search.present? %> + <%= link_to "Clear", cable_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> + <% end %> +
+ <% if @channels.any? %> @@ -36,6 +45,6 @@
<% else %>
-

No cable messages.

+

<%= @search.present? ? "No channels matching “#{@search}”.".html_safe : "No cable messages." %>

<% end %> diff --git a/app/views/solid_stack_web/cable_messages/index.html.erb b/app/views/solid_stack_web/cable_messages/index.html.erb index 7504c59..6106245 100644 --- a/app/views/solid_stack_web/cable_messages/index.html.erb +++ b/app/views/solid_stack_web/cable_messages/index.html.erb @@ -5,6 +5,15 @@ +
+ + <% if @search.present? %> + <%= link_to "Clear", cable_channel_messages_path(params[:channel_hash]), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> + <% end %> +
+ <% if @messages.any? %> @@ -31,6 +40,6 @@ <%== @pagy.series_nav if @pagy.pages > 1 %> <% else %>
-

No messages for this channel.

+

<%= @search.present? ? "No messages matching “#{@search}”.".html_safe : "No messages for this channel." %>

<% end %> \ No newline at end of file diff --git a/spec/requests/solid_stack_web/cable_messages_spec.rb b/spec/requests/solid_stack_web/cable_messages_spec.rb index c9818bf..365b6c6 100644 --- a/spec/requests/solid_stack_web/cable_messages_spec.rb +++ b/spec/requests/solid_stack_web/cable_messages_spec.rb @@ -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 diff --git a/spec/requests/solid_stack_web/cable_spec.rb b/spec/requests/solid_stack_web/cable_spec.rb index 308cc93..10edfcc 100644 --- a/spec/requests/solid_stack_web/cable_spec.rb +++ b/spec/requests/solid_stack_web/cable_spec.rb @@ -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