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

---

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 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
6 changes: 6 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_08_filters.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions app/controllers/solid_stack_web/cable/purges_controller.rb
Original file line number Diff line number Diff line change
@@ -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
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
@@ -1,5 +1,14 @@
<div class="sqw-page-header">
<div class="sqw-page-header sqw-page-header--split">
<h1 class="sqw-page-title">Solid Cable</h1>
<div class="sqw-header-actions">
<%= 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 %>
</div>
</div>

<div class="sqw-stats-grid">
Expand Down
5 changes: 5 additions & 0 deletions app/views/solid_stack_web/cable_messages/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
<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">
<%= 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" %>
</div>
</div>
Expand Down
6 changes: 4 additions & 2 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
76 changes: 76 additions & 0 deletions spec/requests/solid_stack_web/cable_purges_spec.rb
Original file line number Diff line number Diff line change
@@ -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