diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f55dd7..f5806de 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 purge — "Purge Old" form on the channel browser deletes all messages older than 1, 7, or 30 days; "Purge Channel" button on the per-channel message list deletes all messages for that channel; both redirect to the channel browser after purging - 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 60334bc..454c58d 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,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; 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 +- **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; **Purge Channel** button deletes all messages for the channel +- **Message purge** — "Purge Old" form on the channel browser deletes all messages older than 1, 7, or 30 days; confirmation prompt before any destructive action --- diff --git a/ROADMAP.md b/ROADMAP.md index 27fb30d..934d439 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 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/assets/stylesheets/solid_stack_web/_08_filters.css b/app/assets/stylesheets/solid_stack_web/_08_filters.css index 3b4dfd4..9e78bc4 100644 --- a/app/assets/stylesheets/solid_stack_web/_08_filters.css +++ b/app/assets/stylesheets/solid_stack_web/_08_filters.css @@ -30,6 +30,12 @@ cursor: pointer; } +.sqw-inline-form { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + .sqw-period-filter { display: flex; border: 1px solid var(--border); diff --git a/app/controllers/solid_stack_web/cable/channel_purges_controller.rb b/app/controllers/solid_stack_web/cable/channel_purges_controller.rb new file mode 100644 index 0000000..84033e7 --- /dev/null +++ b/app/controllers/solid_stack_web/cable/channel_purges_controller.rb @@ -0,0 +1,8 @@ +module SolidStackWeb + class Cable::ChannelPurgesController < ApplicationController + def destroy + ::SolidCable::Message.where(channel_hash: params[:channel_hash]).delete_all + redirect_to cable_path, notice: "All messages for this channel have been purged." + end + end +end diff --git a/app/controllers/solid_stack_web/cable/purges_controller.rb b/app/controllers/solid_stack_web/cable/purges_controller.rb new file mode 100644 index 0000000..7dabde0 --- /dev/null +++ b/app/controllers/solid_stack_web/cable/purges_controller.rb @@ -0,0 +1,9 @@ +module SolidStackWeb + class Cable::PurgesController < ApplicationController + def destroy + days = [params[:older_than].to_i, 1].max + ::SolidCable::Message.where("created_at < ?", days.days.ago).delete_all + redirect_to cable_path, notice: "Messages older than #{days} #{days == 1 ? "day" : "days"} purged." + 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 4bd9d43..5e9e1e6 100644 --- a/app/views/solid_stack_web/cable/index.html.erb +++ b/app/views/solid_stack_web/cable/index.html.erb @@ -1,5 +1,14 @@ -
+

Solid Cable

+
+ <%= form_with url: cable_purge_path, method: :delete, class: "sqw-inline-form", + data: { turbo_confirm: "Purge these messages? This cannot be undone." } do |f| %> + <%= f.select :older_than, + [["Older than 1 day", 1], ["Older than 7 days", 7], ["Older than 30 days", 30]], + {}, class: "sqw-select" %> + <%= f.submit "Purge Old", class: "sqw-btn sqw-btn--danger sqw-btn--sm" %> + <% 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 6106245..6ec0143 100644 --- a/app/views/solid_stack_web/cable_messages/index.html.erb +++ b/app/views/solid_stack_web/cable_messages/index.html.erb @@ -1,6 +1,11 @@

<%= @channel_name %>

+ <%= button_to "Purge Channel", + cable_channel_purge_path(params[:channel_hash]), + method: :delete, + class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Delete all messages for this channel? This cannot be undone." } %> <%= link_to "← Channels", cable_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
diff --git a/config/routes.rb b/config/routes.rb index c21ac9c..87f36a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,6 +37,8 @@ get "cache", to: "cache#index", as: :cache 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 + get "cable", to: "cable#index", as: :cable + delete "cable/purge", to: "cable/purges#destroy", as: :cable_purge + get "cable/channels/:channel_hash", to: "cable_messages#index", as: :cable_channel_messages + delete "cable/channels/:channel_hash/purge", to: "cable/channel_purges#destroy", as: :cable_channel_purge end diff --git a/spec/requests/solid_stack_web/cable_purges_spec.rb b/spec/requests/solid_stack_web/cable_purges_spec.rb new file mode 100644 index 0000000..7c7ab4a --- /dev/null +++ b/spec/requests/solid_stack_web/cable_purges_spec.rb @@ -0,0 +1,76 @@ +require "rails_helper" + +RSpec.describe "CablePurges", type: :request do + let(:engine_root) { "/solid_stack" } + + def broadcast(channel, payload = "msg") + SolidCable::Message.broadcast(channel, payload) + end + + describe "DELETE /cable/purge" do + it "deletes messages older than the specified number of days" do + broadcast("chat", "old") + SolidCable::Message.update_all(created_at: 10.days.ago) + broadcast("chat", "new") + + delete "#{engine_root}/cable/purge", params: { older_than: 7 } + + remaining = SolidCable::Message.pluck(:payload) + expect(remaining).to include("new") + expect(remaining).not_to include("old") + end + + it "redirects to the cable index" do + delete "#{engine_root}/cable/purge", params: { older_than: 7 } + expect(response).to redirect_to("#{engine_root}/cable") + end + + it "treats a missing older_than as 1 day minimum" do + broadcast("chat", "recent") + SolidCable::Message.update_all(created_at: 2.days.ago) + + delete "#{engine_root}/cable/purge" + + expect(SolidCable::Message.count).to eq(0) + end + + it "does not delete messages newer than the threshold" do + broadcast("chat", "fresh") + + delete "#{engine_root}/cable/purge", params: { older_than: 7 } + + expect(SolidCable::Message.count).to eq(1) + end + end + + describe "DELETE /cable/channels/:channel_hash/purge" do + it "deletes all messages for the channel" do + SolidCable::Message.broadcast("sports", "goal") + SolidCable::Message.broadcast("sports", "offside") + hash = SolidCable::Message.where(channel: "sports").pick(:channel_hash) + + delete "#{engine_root}/cable/channels/#{hash}/purge" + + expect(SolidCable::Message.where(channel: "sports").count).to eq(0) + end + + it "does not delete messages from other channels" do + SolidCable::Message.broadcast("sports", "goal") + SolidCable::Message.broadcast("chat", "hello") + hash = SolidCable::Message.where(channel: "sports").pick(:channel_hash) + + delete "#{engine_root}/cable/channels/#{hash}/purge" + + expect(SolidCable::Message.where(channel: "chat").count).to eq(1) + end + + it "redirects to the cable index" do + SolidCable::Message.broadcast("sports", "goal") + hash = SolidCable::Message.where(channel: "sports").pick(:channel_hash) + + delete "#{engine_root}/cable/channels/#{hash}/purge" + + expect(response).to redirect_to("#{engine_root}/cable") + end + end +end