solid_stack_web 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -2
  3. data/app/assets/stylesheets/solid_stack_web/_01_base.css +3 -2
  4. data/app/assets/stylesheets/solid_stack_web/_02_layout.css +28 -0
  5. data/app/assets/stylesheets/solid_stack_web/_04_table.css +13 -1
  6. data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +49 -1
  7. data/app/assets/stylesheets/solid_stack_web/_08_filters.css +6 -0
  8. data/app/assets/stylesheets/solid_stack_web/_10_responsive.css +54 -0
  9. data/app/assets/stylesheets/solid_stack_web/_12_dark_mode.css +34 -0
  10. data/app/controllers/solid_stack_web/cable/channel_purges_controller.rb +8 -0
  11. data/app/controllers/solid_stack_web/cable/purges_controller.rb +9 -0
  12. data/app/controllers/solid_stack_web/cable_controller.rb +20 -1
  13. data/app/controllers/solid_stack_web/cable_messages_controller.rb +13 -0
  14. data/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb +4 -3
  15. data/app/controllers/solid_stack_web/failed_jobs_controller.rb +2 -1
  16. data/app/controllers/solid_stack_web/jobs/selections_controller.rb +2 -2
  17. data/app/controllers/solid_stack_web/jobs_controller.rb +4 -2
  18. data/app/helpers/solid_stack_web/application_helper.rb +25 -0
  19. data/app/javascript/solid_stack_web/application.js +5 -1
  20. data/app/javascript/solid_stack_web/theme_controller.js +26 -0
  21. data/app/javascript/solid_stack_web/timestamp_controller.js +59 -0
  22. data/app/models/solid_stack_web/cable_stats.rb +15 -2
  23. data/app/models/solid_stack_web/cable_timeline.rb +32 -0
  24. data/app/views/layouts/solid_stack_web/application.html.erb +18 -4
  25. data/app/views/solid_stack_web/cable/index.html.erb +56 -3
  26. data/app/views/solid_stack_web/cable_messages/index.html.erb +56 -0
  27. data/app/views/solid_stack_web/cache_entries/index.html.erb +9 -3
  28. data/app/views/solid_stack_web/cache_entries/show.html.erb +1 -1
  29. data/app/views/solid_stack_web/dashboard/index.html.erb +25 -4
  30. data/app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb +3 -0
  31. data/app/views/solid_stack_web/failed_jobs/index.html.erb +4 -3
  32. data/app/views/solid_stack_web/failed_jobs/show.html.erb +1 -1
  33. data/app/views/solid_stack_web/history/index.html.erb +9 -3
  34. data/app/views/solid_stack_web/jobs/_empty.html.erb +19 -2
  35. data/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb +3 -0
  36. data/app/views/solid_stack_web/jobs/index.html.erb +3 -3
  37. data/app/views/solid_stack_web/jobs/show.html.erb +4 -4
  38. data/app/views/solid_stack_web/processes/index.html.erb +3 -2
  39. data/app/views/solid_stack_web/queues/index.html.erb +2 -1
  40. data/app/views/solid_stack_web/queues/show.html.erb +2 -2
  41. data/app/views/solid_stack_web/recurring_tasks/index.html.erb +5 -5
  42. data/app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb +1 -1
  43. data/app/views/solid_stack_web/shared/_flash.html.erb +1 -0
  44. data/app/views/solid_stack_web/stats/index.html.erb +2 -1
  45. data/config/importmap.rb +2 -0
  46. data/config/routes.rb +4 -1
  47. data/lib/solid_stack_web/version.rb +1 -1
  48. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3159278d6dd76fc0e54660ab9c11536a63037815771ff04206adca43b800c4ef
4
- data.tar.gz: 05f47e20a15b2f70cbf216713cb4c18da3e3f569b64eb1a271543489fecb95c1
3
+ metadata.gz: 8466d9dd8464ac19cb839fa2a2396b28472564503cf07779368ba9fb784e1d5d
4
+ data.tar.gz: b4b3720aa4fb16a595d1670cc6f0f59c99da6ab19fab81aa52dc52119bca6edf
5
5
  SHA512:
6
- metadata.gz: cdd04812d4cdfa1d3c48ff2a81319f8956b71afee1dded7dbce009797822248e422990f02134fbb4f3c88e6e2e2702c2c91c78d94a6d04e28ebd8d1a2b0022b9
7
- data.tar.gz: 431b97295f3682b1512b09ece023c280997560d4b4f065ef742411fddc63275c8257f0d34d02f6bb0589a555c8677d0515301cabfc8b0768465fd42ff6599a54
6
+ metadata.gz: edb56bb1198b125201acc8bffb09a364c487890eab1eb6f18a6fc5759a3786e8748fcda239a83ef6e232064d538dc0d439f7c07775d022e42384de665b793644
7
+ data.tar.gz: e0f469d95f9d9fb03e1654110f7e9751859f735aa3402407f022eb75d2c4c6ad1546d398692324a6bb9c079b6f767fc901aa1e714f0d9e1c27a22b613ca2641b
data/README.md CHANGED
@@ -48,6 +48,10 @@ The dashboard will be available at `/solid_stack` (or whatever path you choose).
48
48
  - **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
49
49
  - **Auto-refresh** — dashboard, jobs, processes, and history views poll automatically; pauses when the tab is hidden or a checkbox is checked; intervals configurable via `dashboard_refresh_interval` and `default_refresh_interval`
50
50
  - **Turbo Stream** job discard — removes the row inline without a full page reload
51
+ - **Dark mode** — toggle button in the header switches between light and dark palettes; preference persisted in `localStorage`; respects `prefers-color-scheme` on first visit
52
+ - **Responsive layout** — stats cards, tables, and two-column grids adapt to narrow viewports; tables scroll horizontally rather than overflow; split page headers stack on small screens
53
+ - **Empty-state improvements** — all list views show a contextual title and an actionable hint; search empty states include a "Clear search" link; filters-active history view offers "Clear filters"; processes and recurring tasks explain the next step
54
+ - **Inline notifications** — bulk and single-job actions surface a flash notice; Turbo Stream discard responses inject the message inline without a full page reload; bulk actions report the affected count ("3 jobs discarded")
51
55
 
52
56
  ### Configuration
53
57
 
@@ -114,7 +118,13 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru
114
118
 
115
119
  ## Solid Cable
116
120
 
117
- _Channel monitoring coming in v0.6.0. Currently shows active message count and distinct channel count on the overview dashboard._
121
+ ### Features
122
+
123
+ - **Dashboard card** — the overview dashboard shows total messages, channels, messages per hour (last 60 minutes), oldest pending message age, and a top-3 channel breakdown by volume
124
+ - **24-hour timeline** — bar chart of message volume on the Cable overview page; each bar represents one hour with a hover tooltip showing the exact count
125
+ - **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
126
+ - **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
127
+ - **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
118
128
 
119
129
  ---
120
130
 
@@ -137,7 +147,7 @@ _Channel monitoring coming in v0.6.0. Currently shows active message count and d
137
147
  "slow_jobs": 7
138
148
  },
139
149
  "cache": { "entries": 1024, "byte_size": 2097152, "oldest_entry": "2026-05-20T10:00:00Z" },
140
- "cable": { "messages": 50, "channels": 3 },
150
+ "cable": { "messages": 50, "channels": 3, "messages_per_hour": 12, "oldest_message": "2026-05-20T10:00:00Z", "top_channels": { "ActionCable::Channel::Base": 30, "ChatChannel": 15, "NotificationsChannel": 5 } },
141
151
  "generated_at": "2026-05-26T10:00:00Z"
142
152
  }
143
153
  ```
@@ -12,8 +12,9 @@
12
12
  --success: #198754;
13
13
  --info: #0dcaf0;
14
14
  --purple: #6f42c1;
15
- --radius: 6px;
16
- --shadow: 0 1px 3px rgba(0,0,0,.08);
15
+ --radius: 6px;
16
+ --shadow: 0 1px 3px rgba(0,0,0,.08);
17
+ --surface-hover: #f9fafb;
17
18
  }
18
19
 
19
20
  body {
@@ -17,6 +17,27 @@
17
17
  height: 52px;
18
18
  }
19
19
 
20
+ .sqw-header__inner .sqw-nav { flex: 1; }
21
+
22
+ .sqw-theme-toggle {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ width: 32px;
27
+ height: 32px;
28
+ padding: 0;
29
+ background: none;
30
+ border: 1px solid var(--border);
31
+ border-radius: var(--radius);
32
+ cursor: pointer;
33
+ font-size: 16px;
34
+ color: var(--text);
35
+ line-height: 1;
36
+ flex-shrink: 0;
37
+ transition: background 0.1s, border-color 0.1s;
38
+ }
39
+ .sqw-theme-toggle:hover { background: var(--bg); }
40
+
20
41
  .sqw-header__logo {
21
42
  font-weight: 700;
22
43
  font-size: 16px;
@@ -71,6 +92,13 @@
71
92
  }
72
93
 
73
94
  .sqw-page-header { margin-bottom: 1.25rem; }
95
+ .sqw-page-header--split {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: space-between;
99
+ gap: 0.75rem;
100
+ flex-wrap: wrap;
101
+ }
74
102
  .sqw-page-title { font-size: 20px; font-weight: 600; }
75
103
  .sqw-page-title-row { display: flex; align-items: center; gap: 0.5rem; }
76
104
 
@@ -25,7 +25,7 @@
25
25
  }
26
26
 
27
27
  .sqw-table tbody tr:last-child td { border-bottom: none; }
28
- .sqw-table tbody tr:hover { background: #f9fafb; }
28
+ .sqw-table tbody tr:hover { background: var(--surface-hover); }
29
29
 
30
30
  .sqw-actions { text-align: right; white-space: nowrap; }
31
31
  .sqw-actions form { display: inline; }
@@ -42,3 +42,15 @@
42
42
  text-align: center;
43
43
  color: var(--muted);
44
44
  }
45
+
46
+ .sqw-empty__title {
47
+ font-size: 15px;
48
+ font-weight: 600;
49
+ color: var(--text);
50
+ margin-bottom: 0.375rem;
51
+ }
52
+
53
+ .sqw-empty__hint {
54
+ font-size: 13px;
55
+ margin-top: 0.375rem;
56
+ }
@@ -114,6 +114,7 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
114
114
  grid-template-columns: 1fr 1fr;
115
115
  gap: 0;
116
116
  margin-top: 1.5rem;
117
+ margin-bottom: 1.5rem;
117
118
  border: 1px solid var(--border);
118
119
  border-radius: var(--radius);
119
120
  background: var(--surface);
@@ -121,11 +122,19 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
121
122
  overflow: hidden;
122
123
  }
123
124
 
125
+ .sqw-timeline-grid--single {
126
+ grid-template-columns: 1fr;
127
+ }
128
+
124
129
  .sqw-timeline-chart {
125
130
  border-top: none;
126
131
  color: var(--purple);
127
132
  }
128
133
 
134
+ .sqw-timeline-chart--cable {
135
+ color: var(--info);
136
+ }
137
+
129
138
  .sqw-timeline-chart + .sqw-timeline-chart {
130
139
  border-left: 1px solid var(--border);
131
140
  }
@@ -160,4 +169,43 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
160
169
  color: var(--muted);
161
170
  margin-top: 3px;
162
171
  opacity: 0.7;
163
- }
172
+ }
173
+
174
+ .sqw-card-list {
175
+ padding: 0.75rem 1.25rem 1rem;
176
+ border-top: 1px solid var(--border);
177
+ }
178
+
179
+ .sqw-card-list__label {
180
+ display: block;
181
+ font-size: 10px;
182
+ font-weight: 500;
183
+ text-transform: uppercase;
184
+ letter-spacing: .06em;
185
+ color: var(--muted);
186
+ margin-bottom: 0.5rem;
187
+ }
188
+
189
+ .sqw-card-list__row {
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: space-between;
193
+ gap: 0.5rem;
194
+ padding: 0.2rem 0;
195
+ }
196
+
197
+ .sqw-card-list__name {
198
+ font-size: 12px;
199
+ color: var(--text);
200
+ overflow: hidden;
201
+ text-overflow: ellipsis;
202
+ white-space: nowrap;
203
+ min-width: 0;
204
+ }
205
+
206
+ .sqw-card-list__count {
207
+ font-size: 12px;
208
+ font-weight: 600;
209
+ color: var(--info);
210
+ flex-shrink: 0;
211
+ }
@@ -30,6 +30,12 @@
30
30
  cursor: pointer;
31
31
  }
32
32
 
33
+ .sqw-inline-form {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ gap: 0.4rem;
37
+ }
38
+
33
39
  .sqw-period-filter {
34
40
  display: flex;
35
41
  border: 1px solid var(--border);
@@ -0,0 +1,54 @@
1
+ @media (max-width: 768px) {
2
+ .sqw-size-grid { grid-template-columns: 1fr; }
3
+ .sqw-timeline-grid:not(.sqw-timeline-grid--single) { grid-template-columns: 1fr; }
4
+ .sqw-timeline-chart + .sqw-timeline-chart { border-left: none; border-top: 1px solid var(--border); }
5
+ }
6
+
7
+ @media (max-width: 640px) {
8
+ .sqw-main {
9
+ padding: 1rem;
10
+ }
11
+
12
+ .sqw-page-header--split {
13
+ flex-direction: column;
14
+ align-items: flex-start;
15
+ }
16
+
17
+ .sqw-table {
18
+ display: block;
19
+ overflow-x: auto;
20
+ border-radius: 0;
21
+ }
22
+
23
+ .sqw-stats-grid {
24
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
25
+ }
26
+
27
+ .sqw-gem-grid {
28
+ grid-template-columns: 1fr;
29
+ }
30
+
31
+ .sqw-truncate {
32
+ max-width: 160px;
33
+ }
34
+
35
+ .sqw-period-filter {
36
+ margin-left: 0;
37
+ }
38
+ }
39
+
40
+ @media (max-width: 576px) {
41
+ .sqw-header__inner {
42
+ padding: 0 1rem;
43
+ gap: 1rem;
44
+ }
45
+
46
+ .sqw-subnav__inner {
47
+ padding: 0 1rem;
48
+ height: auto;
49
+ min-height: 36px;
50
+ flex-wrap: wrap;
51
+ padding-top: 0.25rem;
52
+ padding-bottom: 0.25rem;
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ [data-theme="dark"] {
2
+ --bg: #0d1117;
3
+ --surface: #161b22;
4
+ --border: #30363d;
5
+ --text: #e6edf3;
6
+ --muted: #8b949e;
7
+ --primary: #58a6ff;
8
+ --danger: #f85149;
9
+ --warning: #d29922;
10
+ --success: #3fb950;
11
+ --info: #39c5cf;
12
+ --purple: #bc8cff;
13
+ --shadow: 0 1px 3px rgba(0,0,0,.4);
14
+ --surface-hover: #1c2128;
15
+ }
16
+
17
+ [data-theme="dark"] .sqw-badge--ready { background: rgba(63,185,80,.15); color: var(--success); }
18
+ [data-theme="dark"] .sqw-badge--scheduled { background: rgba(57,197,207,.15); color: var(--info); }
19
+ [data-theme="dark"] .sqw-badge--claimed { background: rgba(88,166,255,.15); color: var(--primary); }
20
+ [data-theme="dark"] .sqw-badge--blocked { background: rgba(210,153,34,.15); color: var(--warning); }
21
+ [data-theme="dark"] .sqw-badge--failed { background: rgba(248,81,73,.15); color: var(--danger); }
22
+ [data-theme="dark"] .sqw-badge--paused { background: rgba(139,148,158,.15); color: var(--muted); }
23
+ [data-theme="dark"] .sqw-badge--queue { background: rgba(139,148,158,.15); color: var(--muted); }
24
+ [data-theme="dark"] .sqw-badge--worker { background: rgba(88,166,255,.15); color: var(--primary); }
25
+ [data-theme="dark"] .sqw-badge--supervisor { background: rgba(63,185,80,.15); color: var(--success); }
26
+ [data-theme="dark"] .sqw-badge--dispatcher { background: rgba(210,153,34,.15); color: var(--warning); }
27
+
28
+ [data-theme="dark"] .sqw-flash--notice { background: #1b3a2b; color: #3fb950; border-color: #2d6a4f; }
29
+ [data-theme="dark"] .sqw-flash--alert { background: #3d1118; color: #f85149; border-color: #6a2030; }
30
+
31
+ [data-theme="dark"] .sqw-theme-toggle {
32
+ border-color: var(--border);
33
+ color: var(--text);
34
+ }
@@ -0,0 +1,8 @@
1
+ module SolidStackWeb
2
+ class Cable::ChannelPurgesController < ApplicationController
3
+ def destroy
4
+ ::SolidCable::Message.where(channel_hash: params[:channel_hash]).delete_all
5
+ redirect_to cable_path, notice: "All messages for this channel have been purged."
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ module SolidStackWeb
2
+ class Cable::PurgesController < ApplicationController
3
+ def destroy
4
+ days = [params[:older_than].to_i, 1].max
5
+ ::SolidCable::Message.where("created_at < ?", days.days.ago).delete_all
6
+ redirect_to cable_path, notice: "Messages older than #{days} #{days == 1 ? "day" : "days"} purged."
7
+ end
8
+ end
9
+ end
@@ -1,8 +1,27 @@
1
1
  module SolidStackWeb
2
2
  class CableController < ApplicationController
3
3
  def index
4
+ @search = params[:q].presence
4
5
  @total_messages = ::SolidCable::Message.count
5
- @channels = ::SolidCable::Message.distinct.pluck(:channel).sort
6
+ @channels = channel_rows
7
+ @timeline = CableTimeline.new
8
+ end
9
+
10
+ private
11
+
12
+ def channel_rows
13
+ scope = ::SolidCable::Message
14
+ if @search
15
+ scope = scope.where("channel LIKE ?", "%#{::ActiveRecord::Base.sanitize_sql_like(@search)}%")
16
+ end
17
+ scope
18
+ .group(:channel, :channel_hash)
19
+ .order(Arel.sql("MAX(created_at) DESC"))
20
+ .pluck(:channel, :channel_hash, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)"))
21
+ .map do |ch, hash, cnt, last_at|
22
+ last_at = Time.zone.parse(last_at) if last_at.is_a?(String)
23
+ { channel: ch, channel_hash: hash, message_count: cnt, last_message_at: last_at }
24
+ end
6
25
  end
7
26
  end
8
27
  end
@@ -0,0 +1,13 @@
1
+ module SolidStackWeb
2
+ class CableMessagesController < ApplicationController
3
+ def index
4
+ @search = params[:q].presence
5
+ scope = ::SolidCable::Message.where(channel_hash: params[:channel_hash])
6
+ if @search
7
+ scope = scope.where("payload LIKE ?", "%#{::ActiveRecord::Base.sanitize_sql_like(@search)}%")
8
+ end
9
+ @channel_name = ::SolidCable::Message.where(channel_hash: params[:channel_hash]).pick(:channel) || params[:channel_hash]
10
+ @pagy, @messages = pagy(scope.order(created_at: :desc))
11
+ end
12
+ end
13
+ end
@@ -4,16 +4,17 @@ module SolidStackWeb
4
4
  before_action :set_ids
5
5
 
6
6
  def create
7
+ count = @ids.size
7
8
  SolidQueue::FailedExecution.where(id: @ids).each(&:retry)
8
- redirect_to failed_jobs_path
9
+ redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} retried."
9
10
  rescue => e
10
11
  redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
11
12
  end
12
13
 
13
14
  def destroy
14
15
  job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id)
15
- SolidQueue::Job.where(id: job_ids).destroy_all
16
- redirect_to failed_jobs_path
16
+ count = SolidQueue::Job.where(id: job_ids).destroy_all.size
17
+ redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
17
18
  rescue => e
18
19
  redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
19
20
  end
@@ -25,6 +25,7 @@ module SolidStackWeb
25
25
  @execution = ::SolidQueue::FailedExecution.find(params[:id])
26
26
  @execution.job.destroy!
27
27
  @executions_remain = ::SolidQueue::FailedExecution.exists?
28
+ @notice = "Job discarded."
28
29
 
29
30
  respond_to do |format|
30
31
  format.html { redirect_to failed_jobs_path }
@@ -35,7 +36,7 @@ module SolidStackWeb
35
36
  def retry
36
37
  execution = ::SolidQueue::FailedExecution.find(params[:id])
37
38
  execution.retry
38
- redirect_to failed_jobs_path
39
+ redirect_to failed_jobs_path, notice: "Job retried."
39
40
  end
40
41
 
41
42
  private
@@ -7,7 +7,7 @@ module SolidStackWeb
7
7
 
8
8
  ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
9
9
  job_ids = Job::EXECUTION_MODELS[status].where(id: ids).pluck(:job_id)
10
- SolidQueue::Job.where(id: job_ids).destroy_all
10
+ count = SolidQueue::Job.where(id: job_ids).destroy_all.size
11
11
 
12
12
  redirect_to jobs_path(
13
13
  status: status,
@@ -15,7 +15,7 @@ module SolidStackWeb
15
15
  queue: params[:queue].presence,
16
16
  period: params[:period].presence_in(PERIOD_DURATIONS.keys),
17
17
  priority: params[:priority].presence
18
- )
18
+ ), notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
19
19
  rescue ArgumentError => e
20
20
  redirect_to jobs_path(status: params[:status]), alert: e.message
21
21
  end
@@ -30,6 +30,7 @@ module SolidStackWeb
30
30
  @execution = Job::EXECUTION_MODELS[@status].find(params[:id])
31
31
  @execution.job.destroy!
32
32
  @executions_remain = Job::EXECUTION_MODELS[@status].exists?
33
+ @notice = "Job discarded."
33
34
 
34
35
  respond_to do |format|
35
36
  format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority) }
@@ -37,8 +38,9 @@ module SolidStackWeb
37
38
  end
38
39
  else
39
40
  job_ids = filtered_scope.pluck(:job_id)
40
- SolidQueue::Job.where(id: job_ids).destroy_all
41
- redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority)
41
+ count = SolidQueue::Job.where(id: job_ids).destroy_all.size
42
+ redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
43
+ notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
42
44
  end
43
45
  end
44
46
 
@@ -1,5 +1,18 @@
1
1
  module SolidStackWeb
2
2
  module ApplicationHelper
3
+ def local_time(time, format: :short, placeholder: "—")
4
+ return placeholder if time.nil?
5
+
6
+ iso = time.utc.iso8601
7
+ fallback = case format
8
+ when :long then time.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
9
+ when :relative then "#{time_ago_in_words(time)} ago"
10
+ else time.utc.strftime("%b %-d %H:%M UTC")
11
+ end
12
+ tag.time(fallback, datetime: iso,
13
+ data: { controller: "timestamp", timestamp_format_value: format })
14
+ end
15
+
3
16
  def format_cache_value(raw)
4
17
  str = raw.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
5
18
  parsed = JSON.parse(str)
@@ -42,6 +55,18 @@ module SolidStackWeb
42
55
  end
43
56
  end
44
57
 
58
+ def cable_messages_timeline_svg(timeline)
59
+ build_sparkline_svg(
60
+ Struct.new(:buckets, :max).new(timeline.message_buckets, timeline.message_max),
61
+ css_class: "sqw-sparkline sqw-sparkline--lg",
62
+ aria_label: "Cable messages over the last 24 hours"
63
+ ) do |count, i|
64
+ hours_ago = CableTimeline::HOURS - 1 - i
65
+ label = count == 1 ? "message" : "messages"
66
+ hours_ago.zero? ? "#{count} #{label} this hour" : "#{count} #{label} #{hours_ago}h ago"
67
+ end
68
+ end
69
+
45
70
  def throughput_sparkline_svg(sparkline)
46
71
  build_sparkline_svg(sparkline, aria_label: "Throughput over the last 12 hours") do |count, i|
47
72
  hours_ago = SolidStackWeb::ThroughputSparkline::HOURS - i
@@ -4,9 +4,13 @@ import RefreshController from "solid_stack_web/refresh_controller"
4
4
  import SearchController from "solid_stack_web/search_controller"
5
5
  import SelectionController from "solid_stack_web/selection_controller"
6
6
  import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
7
+ import ThemeController from "solid_stack_web/theme_controller"
8
+ import TimestampController from "solid_stack_web/timestamp_controller"
7
9
 
8
10
  const application = Application.start()
9
11
  application.register("refresh", RefreshController)
10
12
  application.register("search", SearchController)
11
13
  application.register("selection", SelectionController)
12
- application.register("sparkline-tooltip", SparklineTooltipController)
14
+ application.register("sparkline-tooltip", SparklineTooltipController)
15
+ application.register("theme", ThemeController)
16
+ application.register("timestamp", TimestampController)
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["toggle"]
5
+
6
+ connect() {
7
+ const saved = localStorage.getItem("sqw-theme")
8
+ const preferred = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
9
+ this.apply(saved || preferred)
10
+ }
11
+
12
+ toggle() {
13
+ const current = document.documentElement.getAttribute("data-theme") || "light"
14
+ const next = current === "dark" ? "light" : "dark"
15
+ localStorage.setItem("sqw-theme", next)
16
+ this.apply(next)
17
+ }
18
+
19
+ apply(theme) {
20
+ document.documentElement.setAttribute("data-theme", theme)
21
+ if (this.hasToggleTarget) {
22
+ this.toggleTarget.textContent = theme === "dark" ? "☀" : "☽"
23
+ this.toggleTarget.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode")
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,59 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { format: { type: String, default: "short" } }
5
+
6
+ connect() {
7
+ const dt = this.element.getAttribute("datetime")
8
+ if (!dt) return
9
+ const date = new Date(dt)
10
+ if (isNaN(date.getTime())) return
11
+ this.element.textContent = this.formatDate(date)
12
+ if (this.formatValue === "relative") {
13
+ this.element.title = this.fullFormat(date)
14
+ }
15
+ }
16
+
17
+ formatDate(date) {
18
+ switch (this.formatValue) {
19
+ case "long":
20
+ return new Intl.DateTimeFormat(undefined, {
21
+ year: "numeric", month: "2-digit", day: "2-digit",
22
+ hour: "2-digit", minute: "2-digit", second: "2-digit"
23
+ }).format(date)
24
+ case "relative":
25
+ return this.relativeFormat(date)
26
+ default:
27
+ return new Intl.DateTimeFormat(undefined, {
28
+ month: "short", day: "numeric",
29
+ hour: "2-digit", minute: "2-digit"
30
+ }).format(date)
31
+ }
32
+ }
33
+
34
+ fullFormat(date) {
35
+ return new Intl.DateTimeFormat(undefined, {
36
+ year: "numeric", month: "short", day: "numeric",
37
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
38
+ timeZoneName: "short"
39
+ }).format(date)
40
+ }
41
+
42
+ relativeFormat(date) {
43
+ const diff = date.getTime() - Date.now()
44
+ const absDiff = Math.abs(diff)
45
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
46
+
47
+ const seconds = Math.floor(absDiff / 1000)
48
+ if (seconds < 60) return rtf.format(diff < 0 ? -seconds : seconds, "second")
49
+
50
+ const minutes = Math.floor(absDiff / 60000)
51
+ if (minutes < 60) return rtf.format(diff < 0 ? -minutes : minutes, "minute")
52
+
53
+ const hours = Math.floor(absDiff / 3600000)
54
+ if (hours < 24) return rtf.format(diff < 0 ? -hours : hours, "hour")
55
+
56
+ const days = Math.floor(absDiff / 86400000)
57
+ return rtf.format(diff < 0 ? -days : days, "day")
58
+ }
59
+ }
@@ -2,9 +2,22 @@ module SolidStackWeb
2
2
  class CableStats
3
3
  def to_h
4
4
  {
5
- messages: ::SolidCable::Message.count,
6
- channels: ::SolidCable::Message.distinct.count(:channel)
5
+ messages: ::SolidCable::Message.count,
6
+ channels: ::SolidCable::Message.distinct.count(:channel),
7
+ messages_per_hour: ::SolidCable::Message.where(created_at: 1.hour.ago..).count,
8
+ oldest_message: ::SolidCable::Message.minimum(:created_at),
9
+ top_channels: top_channels_by_volume
7
10
  }
8
11
  end
12
+
13
+ private
14
+
15
+ def top_channels_by_volume
16
+ ::SolidCable::Message
17
+ .group(:channel)
18
+ .order(Arel.sql("count(*) DESC"))
19
+ .limit(3)
20
+ .count
21
+ end
9
22
  end
10
23
  end
@@ -0,0 +1,32 @@
1
+ module SolidStackWeb
2
+ class CableTimeline
3
+ HOURS = 24
4
+
5
+ def message_buckets
6
+ @message_buckets ||= build_buckets { |rows, from, to| rows.count { |t| t >= from && t < to } }
7
+ end
8
+
9
+ def message_max
10
+ message_buckets.max || 0
11
+ end
12
+
13
+ private
14
+
15
+ def rows
16
+ @rows ||= begin
17
+ origin = Time.current - HOURS.hours
18
+ ::SolidCable::Message.where(created_at: origin..Time.current).pluck(:created_at)
19
+ end
20
+ end
21
+
22
+ def build_buckets(&block)
23
+ now = Time.current
24
+ origin = now - HOURS.hours
25
+ HOURS.times.map do |i|
26
+ from = origin + i.hours
27
+ to = origin + (i + 1).hours
28
+ block.call(rows, from, to)
29
+ end
30
+ end
31
+ end
32
+ end