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.
- checksums.yaml +4 -4
- data/README.md +12 -2
- data/app/assets/stylesheets/solid_stack_web/_01_base.css +3 -2
- data/app/assets/stylesheets/solid_stack_web/_02_layout.css +28 -0
- data/app/assets/stylesheets/solid_stack_web/_04_table.css +13 -1
- data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +49 -1
- data/app/assets/stylesheets/solid_stack_web/_08_filters.css +6 -0
- data/app/assets/stylesheets/solid_stack_web/_10_responsive.css +54 -0
- data/app/assets/stylesheets/solid_stack_web/_12_dark_mode.css +34 -0
- data/app/controllers/solid_stack_web/cable/channel_purges_controller.rb +8 -0
- data/app/controllers/solid_stack_web/cable/purges_controller.rb +9 -0
- data/app/controllers/solid_stack_web/cable_controller.rb +20 -1
- data/app/controllers/solid_stack_web/cable_messages_controller.rb +13 -0
- data/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb +4 -3
- data/app/controllers/solid_stack_web/failed_jobs_controller.rb +2 -1
- data/app/controllers/solid_stack_web/jobs/selections_controller.rb +2 -2
- data/app/controllers/solid_stack_web/jobs_controller.rb +4 -2
- data/app/helpers/solid_stack_web/application_helper.rb +25 -0
- data/app/javascript/solid_stack_web/application.js +5 -1
- data/app/javascript/solid_stack_web/theme_controller.js +26 -0
- data/app/javascript/solid_stack_web/timestamp_controller.js +59 -0
- data/app/models/solid_stack_web/cable_stats.rb +15 -2
- data/app/models/solid_stack_web/cable_timeline.rb +32 -0
- data/app/views/layouts/solid_stack_web/application.html.erb +18 -4
- data/app/views/solid_stack_web/cable/index.html.erb +56 -3
- data/app/views/solid_stack_web/cable_messages/index.html.erb +56 -0
- data/app/views/solid_stack_web/cache_entries/index.html.erb +9 -3
- data/app/views/solid_stack_web/cache_entries/show.html.erb +1 -1
- data/app/views/solid_stack_web/dashboard/index.html.erb +25 -4
- data/app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb +3 -0
- data/app/views/solid_stack_web/failed_jobs/index.html.erb +4 -3
- data/app/views/solid_stack_web/failed_jobs/show.html.erb +1 -1
- data/app/views/solid_stack_web/history/index.html.erb +9 -3
- data/app/views/solid_stack_web/jobs/_empty.html.erb +19 -2
- data/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb +3 -0
- data/app/views/solid_stack_web/jobs/index.html.erb +3 -3
- data/app/views/solid_stack_web/jobs/show.html.erb +4 -4
- data/app/views/solid_stack_web/processes/index.html.erb +3 -2
- data/app/views/solid_stack_web/queues/index.html.erb +2 -1
- data/app/views/solid_stack_web/queues/show.html.erb +2 -2
- data/app/views/solid_stack_web/recurring_tasks/index.html.erb +5 -5
- data/app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb +1 -1
- data/app/views/solid_stack_web/shared/_flash.html.erb +1 -0
- data/app/views/solid_stack_web/stats/index.html.erb +2 -1
- data/config/importmap.rb +2 -0
- data/config/routes.rb +4 -1
- data/lib/solid_stack_web/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8466d9dd8464ac19cb839fa2a2396b28472564503cf07779368ba9fb784e1d5d
|
|
4
|
+
data.tar.gz: b4b3720aa4fb16a595d1670cc6f0f59c99da6ab19fab81aa52dc52119bca6edf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
```
|
|
@@ -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:
|
|
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
|
+
}
|
|
@@ -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 =
|
|
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:
|
|
6
|
-
channels:
|
|
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
|