solid_stack_web 0.4.0 → 0.6.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 +24 -4
- data/app/assets/stylesheets/solid_stack_web/_03_stats.css +27 -0
- data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +72 -3
- data/app/assets/stylesheets/solid_stack_web/_08_filters.css +6 -0
- data/app/assets/stylesheets/solid_stack_web/_09_detail.css +42 -0
- data/app/controllers/solid_stack_web/application_controller.rb +1 -1
- 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/cache/flushes_controller.rb +8 -0
- data/app/controllers/solid_stack_web/cache_controller.rb +2 -0
- data/app/controllers/solid_stack_web/cache_entries_controller.rb +32 -0
- data/app/helpers/solid_stack_web/application_helper.rb +44 -0
- data/app/javascript/solid_stack_web/application.js +2 -0
- data/app/javascript/solid_stack_web/search_controller.js +16 -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/models/solid_stack_web/cache_size_stats.rb +33 -0
- data/app/models/solid_stack_web/cache_stats.rb +3 -2
- data/app/models/solid_stack_web/cache_timeline.rb +40 -0
- data/app/views/layouts/solid_stack_web/application.html.erb +21 -0
- data/app/views/solid_stack_web/cable/index.html.erb +50 -3
- data/app/views/solid_stack_web/cable_messages/index.html.erb +50 -0
- data/app/views/solid_stack_web/cache/index.html.erb +84 -2
- data/app/views/solid_stack_web/cache_entries/index.html.erb +66 -0
- data/app/views/solid_stack_web/cache_entries/show.html.erb +48 -0
- data/app/views/solid_stack_web/dashboard/index.html.erb +32 -5
- data/app/views/solid_stack_web/failed_jobs/index.html.erb +1 -1
- data/app/views/solid_stack_web/history/index.html.erb +4 -4
- data/app/views/solid_stack_web/jobs/index.html.erb +8 -6
- data/app/views/solid_stack_web/queues/show.html.erb +1 -1
- data/config/importmap.rb +1 -0
- data/config/routes.rb +6 -1
- data/lib/solid_stack_web/version.rb +1 -1
- data/lib/solid_stack_web.rb +5 -1
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d16b6a549b38e32fd792d4bff55d990ae5a80d458dcbc47df54759c947536c6
|
|
4
|
+
data.tar.gz: 2ff73287be32fa8a3d3a1089ebd71656bed2ce80742cd11296a3458e0ebeaae3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd5aefe87facc4b0ac5f9c2265fa714d6d70127b4c785fc450a210dbcb0958bd03b7aef09de7b0555d4b54de7defe702a464c296357fdb49411b5b532dce341d
|
|
7
|
+
data.tar.gz: 8a0527f29907807f16711e72bbfc13833a0be47481d4e540e1ad1a324dad63eb73da4c5f5c0043bfcdfd6ae458d5163af66e2e350fd9c5ecd76fe13c70b35913
|
data/README.md
CHANGED
|
@@ -74,6 +74,10 @@ SolidStackWeb.configure do |config|
|
|
|
74
74
|
|
|
75
75
|
# Maximum results shown by the search feature (default: 25).
|
|
76
76
|
config.search_results_limit = 25
|
|
77
|
+
|
|
78
|
+
# Show the raw serialized value on the cache entry detail page (default: false).
|
|
79
|
+
# Disable for stores that contain sensitive data.
|
|
80
|
+
config.allow_value_preview = true
|
|
77
81
|
end
|
|
78
82
|
```
|
|
79
83
|
|
|
@@ -94,13 +98,29 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru
|
|
|
94
98
|
|
|
95
99
|
## Solid Cache
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
### Features
|
|
102
|
+
|
|
103
|
+
- **Overview dashboard card** — live entry count (linked to the entry browser), total byte size, and oldest-entry age (`time_ago_in_words` with exact timestamp on hover; hidden when cache is empty)
|
|
104
|
+
- **Cache timeline** — two side-by-side 24-hour bar charts showing entries written per hour and bytes written per hour; each bar has a hover tooltip
|
|
105
|
+
- **Size distribution** — byte-range histogram (< 1 KB → > 1 MB) with entry counts and proportional inline bars; hidden when cache is empty
|
|
106
|
+
- **Largest entries** — top 10 entries by byte size, each linked to the detail page; hidden when cache is empty
|
|
107
|
+
- **Entry browser** — `GET /cache/entries` lists all `SolidCache::Entry` records in a paginated, sortable table; columns: key, byte size, created-at; sortable by any column; key auto-submits search after 4 characters
|
|
108
|
+
- **Key search** — filter entries by key substring; results update automatically after 4 characters
|
|
109
|
+
- **Entry detail page** — `GET /cache/entries/:id` shows the full key, byte size, and created-at; optionally displays the raw serialized value (see `allow_value_preview` below)
|
|
110
|
+
- **Delete entry** — per-row delete button or detail-page button removes a single cache entry
|
|
111
|
+
- **Flush All** — header button deletes every cache entry with a confirmation prompt
|
|
98
112
|
|
|
99
113
|
---
|
|
100
114
|
|
|
101
115
|
## Solid Cable
|
|
102
116
|
|
|
103
|
-
|
|
117
|
+
### Features
|
|
118
|
+
|
|
119
|
+
- **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
|
|
120
|
+
- **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
|
|
121
|
+
- **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
|
|
122
|
+
- **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
|
|
123
|
+
- **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
|
|
104
124
|
|
|
105
125
|
---
|
|
106
126
|
|
|
@@ -122,8 +142,8 @@ _Channel monitoring coming in v0.6.0. Currently shows active message count and d
|
|
|
122
142
|
"processes_stale": 0,
|
|
123
143
|
"slow_jobs": 7
|
|
124
144
|
},
|
|
125
|
-
"cache": { "entries": 1024, "byte_size": 2097152 },
|
|
126
|
-
"cable": { "messages": 50, "channels": 3 },
|
|
145
|
+
"cache": { "entries": 1024, "byte_size": 2097152, "oldest_entry": "2026-05-20T10:00:00Z" },
|
|
146
|
+
"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 } },
|
|
127
147
|
"generated_at": "2026-05-26T10:00:00Z"
|
|
128
148
|
}
|
|
129
149
|
```
|
|
@@ -22,6 +22,33 @@ a.sqw-stat:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); text-decoration: none;
|
|
|
22
22
|
.sqw-stat__label { font-size: 12px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
23
23
|
.sqw-stat__value { font-size: 28px; font-weight: 700; line-height: 1; }
|
|
24
24
|
|
|
25
|
+
.sqw-size-grid {
|
|
26
|
+
display: grid;
|
|
27
|
+
grid-template-columns: 1fr 1fr;
|
|
28
|
+
gap: 1.5rem;
|
|
29
|
+
margin-top: 1.5rem;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.sqw-size-section { }
|
|
33
|
+
|
|
34
|
+
.sqw-dist-bar-cell {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 0.5rem;
|
|
38
|
+
min-width: 160px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.sqw-dist-bar {
|
|
42
|
+
height: 8px;
|
|
43
|
+
background: var(--purple);
|
|
44
|
+
border-radius: 4px;
|
|
45
|
+
min-width: 2px;
|
|
46
|
+
opacity: 0.7;
|
|
47
|
+
transition: width 0.3s ease;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.sqw-dist-pct { font-size: 12px; white-space: nowrap; }
|
|
51
|
+
|
|
25
52
|
.sqw-stat--ready .sqw-stat__value { color: var(--success); }
|
|
26
53
|
.sqw-stat--scheduled .sqw-stat__value { color: var(--info); }
|
|
27
54
|
.sqw-stat--claimed .sqw-stat__value { color: var(--primary); }
|
|
@@ -69,6 +69,7 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
.sqw-inline-stat__value { font-size: 28px; font-weight: 700; line-height: 1; }
|
|
72
|
+
.sqw-inline-stat__value--sm { font-size: 16px; }
|
|
72
73
|
|
|
73
74
|
.sqw-inline-stat--ready .sqw-inline-stat__value { color: var(--success); }
|
|
74
75
|
.sqw-inline-stat--scheduled .sqw-inline-stat__value { color: var(--info); }
|
|
@@ -105,8 +106,37 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
|
|
|
105
106
|
height: 40px;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
.sqw-sparkline--sm {
|
|
109
|
-
|
|
109
|
+
.sqw-sparkline--sm { height: 24px; }
|
|
110
|
+
.sqw-sparkline--lg { height: 64px; }
|
|
111
|
+
|
|
112
|
+
.sqw-timeline-grid {
|
|
113
|
+
display: grid;
|
|
114
|
+
grid-template-columns: 1fr 1fr;
|
|
115
|
+
gap: 0;
|
|
116
|
+
margin-top: 1.5rem;
|
|
117
|
+
margin-bottom: 1.5rem;
|
|
118
|
+
border: 1px solid var(--border);
|
|
119
|
+
border-radius: var(--radius);
|
|
120
|
+
background: var(--surface);
|
|
121
|
+
box-shadow: var(--shadow);
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.sqw-timeline-grid--single {
|
|
126
|
+
grid-template-columns: 1fr;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.sqw-timeline-chart {
|
|
130
|
+
border-top: none;
|
|
131
|
+
color: var(--purple);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.sqw-timeline-chart--cable {
|
|
135
|
+
color: var(--info);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.sqw-timeline-chart + .sqw-timeline-chart {
|
|
139
|
+
border-left: 1px solid var(--border);
|
|
110
140
|
}
|
|
111
141
|
|
|
112
142
|
.sqw-queue-sparkline {
|
|
@@ -139,4 +169,43 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
|
|
|
139
169
|
color: var(--muted);
|
|
140
170
|
margin-top: 3px;
|
|
141
171
|
opacity: 0.7;
|
|
142
|
-
}
|
|
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
|
+
}
|
|
@@ -70,6 +70,48 @@
|
|
|
70
70
|
overflow-y: auto;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
.sqw-detail {
|
|
74
|
+
display: grid;
|
|
75
|
+
grid-template-columns: auto 1fr;
|
|
76
|
+
gap: 0.5rem 1.5rem;
|
|
77
|
+
font-size: 13px;
|
|
78
|
+
margin-bottom: 1.5rem;
|
|
79
|
+
}
|
|
80
|
+
.sqw-detail__row {
|
|
81
|
+
display: contents;
|
|
82
|
+
}
|
|
83
|
+
.sqw-detail__row dt { color: var(--muted); white-space: nowrap; align-self: start; padding-top: 0.15rem; }
|
|
84
|
+
.sqw-detail__row dd { word-break: break-all; }
|
|
85
|
+
|
|
86
|
+
.sqw-value-preview { margin-top: 1rem; }
|
|
87
|
+
|
|
88
|
+
.sqw-value-preview__header {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 0.5rem;
|
|
92
|
+
margin-bottom: 0.75rem;
|
|
93
|
+
}
|
|
94
|
+
.sqw-value-preview__header .sqw-section-title { margin-bottom: 0; }
|
|
95
|
+
|
|
96
|
+
.sqw-value-pre {
|
|
97
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
background: var(--bg);
|
|
100
|
+
border: 1px solid var(--border);
|
|
101
|
+
border-radius: var(--radius);
|
|
102
|
+
padding: 0.75rem;
|
|
103
|
+
overflow-x: auto;
|
|
104
|
+
white-space: pre-wrap;
|
|
105
|
+
word-break: break-word;
|
|
106
|
+
max-height: 500px;
|
|
107
|
+
overflow-y: auto;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.sqw-value-truncated { font-size: 12px; margin-top: 0.5rem; }
|
|
111
|
+
|
|
112
|
+
.sqw-link { color: var(--primary); text-decoration: none; }
|
|
113
|
+
.sqw-link:hover { text-decoration: underline; }
|
|
114
|
+
|
|
73
115
|
.sqw-code-input {
|
|
74
116
|
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
|
75
117
|
font-size: 12px;
|
|
@@ -16,7 +16,7 @@ module SolidStackWeb
|
|
|
16
16
|
def current_section
|
|
17
17
|
case controller_name
|
|
18
18
|
when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks" then :queue
|
|
19
|
-
when "cache" then :cache
|
|
19
|
+
when "cache", "cache_entries" then :cache
|
|
20
20
|
when "cable" then :cable
|
|
21
21
|
else :overview
|
|
22
22
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module SolidStackWeb
|
|
2
|
+
class CacheEntriesController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@search = params[:q].presence
|
|
5
|
+
@sort = resolve_sort
|
|
6
|
+
|
|
7
|
+
scope = ::SolidCache::Entry.all
|
|
8
|
+
scope = scope.where("key LIKE ?", "%#{::ActiveRecord::Base.sanitize_sql_like(@search)}%") if @search
|
|
9
|
+
scope = scope.order(@sort["column"] => @sort["direction"])
|
|
10
|
+
|
|
11
|
+
@pagy, @entries = pagy(scope)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
@entry = ::SolidCache::Entry.find(params[:id])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def destroy
|
|
19
|
+
::SolidCache::Entry.find(params[:id]).destroy
|
|
20
|
+
redirect_to cache_entries_path(q: params[:q], column: params[:column], direction: params[:direction]),
|
|
21
|
+
notice: "Cache entry deleted."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def resolve_sort
|
|
27
|
+
column = %w[byte_size created_at key].include?(params[:column]) ? params[:column] : "byte_size"
|
|
28
|
+
direction = %w[asc desc].include?(params[:direction]) ? params[:direction] : "desc"
|
|
29
|
+
{ "column" => column, "direction" => direction }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
module SolidStackWeb
|
|
2
2
|
module ApplicationHelper
|
|
3
|
+
def format_cache_value(raw)
|
|
4
|
+
str = raw.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
5
|
+
parsed = JSON.parse(str)
|
|
6
|
+
{ label: "JSON", content: JSON.pretty_generate(parsed) }
|
|
7
|
+
rescue JSON::ParserError, JSON::GeneratorError
|
|
8
|
+
{ label: "Text", content: str }
|
|
9
|
+
end
|
|
10
|
+
|
|
3
11
|
def format_duration(seconds)
|
|
4
12
|
return "—" if seconds.nil?
|
|
5
13
|
return "#{(seconds * 1000).round}ms" if seconds < 1
|
|
@@ -10,6 +18,42 @@ module SolidStackWeb
|
|
|
10
18
|
"#{s / 3600}h #{(s % 3600) / 60}m"
|
|
11
19
|
end
|
|
12
20
|
|
|
21
|
+
def cache_entries_timeline_svg(timeline)
|
|
22
|
+
build_sparkline_svg(
|
|
23
|
+
Struct.new(:buckets, :max).new(timeline.entry_buckets, timeline.entry_max),
|
|
24
|
+
css_class: "sqw-sparkline sqw-sparkline--lg",
|
|
25
|
+
aria_label: "Cache entries written over the last 24 hours"
|
|
26
|
+
) do |count, i|
|
|
27
|
+
hours_ago = CacheTimeline::HOURS - 1 - i
|
|
28
|
+
hours_ago.zero? ? "#{count} #{"entry".then { |w| count == 1 ? w : "entries" }} this hour" \
|
|
29
|
+
: "#{count} #{"entry".then { |w| count == 1 ? w : "entries" }} #{hours_ago}h ago"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cache_bytes_timeline_svg(timeline)
|
|
34
|
+
build_sparkline_svg(
|
|
35
|
+
Struct.new(:buckets, :max).new(timeline.byte_buckets, timeline.byte_max),
|
|
36
|
+
css_class: "sqw-sparkline sqw-sparkline--lg",
|
|
37
|
+
aria_label: "Cache bytes written over the last 24 hours"
|
|
38
|
+
) do |bytes, i|
|
|
39
|
+
hours_ago = CacheTimeline::HOURS - 1 - i
|
|
40
|
+
size = number_to_human_size(bytes)
|
|
41
|
+
hours_ago.zero? ? "#{size} written this hour" : "#{size} written #{hours_ago}h ago"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cable_messages_timeline_svg(timeline)
|
|
46
|
+
build_sparkline_svg(
|
|
47
|
+
Struct.new(:buckets, :max).new(timeline.message_buckets, timeline.message_max),
|
|
48
|
+
css_class: "sqw-sparkline sqw-sparkline--lg",
|
|
49
|
+
aria_label: "Cable messages over the last 24 hours"
|
|
50
|
+
) do |count, i|
|
|
51
|
+
hours_ago = CableTimeline::HOURS - 1 - i
|
|
52
|
+
label = count == 1 ? "message" : "messages"
|
|
53
|
+
hours_ago.zero? ? "#{count} #{label} this hour" : "#{count} #{label} #{hours_ago}h ago"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
13
57
|
def throughput_sparkline_svg(sparkline)
|
|
14
58
|
build_sparkline_svg(sparkline, aria_label: "Throughput over the last 12 hours") do |count, i|
|
|
15
59
|
hours_ago = SolidStackWeb::ThroughputSparkline::HOURS - i
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import "@hotwired/turbo"
|
|
2
2
|
import { Application } from "@hotwired/stimulus"
|
|
3
3
|
import RefreshController from "solid_stack_web/refresh_controller"
|
|
4
|
+
import SearchController from "solid_stack_web/search_controller"
|
|
4
5
|
import SelectionController from "solid_stack_web/selection_controller"
|
|
5
6
|
import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
|
|
6
7
|
|
|
7
8
|
const application = Application.start()
|
|
8
9
|
application.register("refresh", RefreshController)
|
|
10
|
+
application.register("search", SearchController)
|
|
9
11
|
application.register("selection", SelectionController)
|
|
10
12
|
application.register("sparkline-tooltip", SparklineTooltipController)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
filter({ target }) {
|
|
5
|
+
clearTimeout(this._timer)
|
|
6
|
+
const len = target.value.length
|
|
7
|
+
if (len >= 4 || len === 0) {
|
|
8
|
+
this._timer = setTimeout(() => target.form.requestSubmit(), 300)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
select({ target }) {
|
|
13
|
+
clearTimeout(this._timer)
|
|
14
|
+
target.form.requestSubmit()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -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
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module SolidStackWeb
|
|
2
|
+
class CacheSizeStats
|
|
3
|
+
BUCKETS = [
|
|
4
|
+
{ label: "< 1 KB", min: 0, max: 1_024 },
|
|
5
|
+
{ label: "1 – 10 KB", min: 1_024, max: 10_240 },
|
|
6
|
+
{ label: "10 – 100 KB", min: 10_240, max: 102_400 },
|
|
7
|
+
{ label: "100 KB – 1 MB", min: 102_400, max: 1_048_576 },
|
|
8
|
+
{ label: "> 1 MB", min: 1_048_576, max: nil }
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
TOP_N = 10
|
|
12
|
+
|
|
13
|
+
def top_entries
|
|
14
|
+
@top_entries ||= ::SolidCache::Entry
|
|
15
|
+
.select(:id, :key, :byte_size)
|
|
16
|
+
.order(byte_size: :desc)
|
|
17
|
+
.limit(TOP_N)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def buckets
|
|
21
|
+
@buckets ||= BUCKETS.map do |b|
|
|
22
|
+
scope = ::SolidCache::Entry.all
|
|
23
|
+
scope = scope.where("byte_size >= ?", b[:min]) if b[:min] > 0
|
|
24
|
+
scope = scope.where("byte_size < ?", b[:max]) if b[:max]
|
|
25
|
+
{ label: b[:label], count: scope.count }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def total
|
|
30
|
+
@total ||= ::SolidCache::Entry.count
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -2,8 +2,9 @@ module SolidStackWeb
|
|
|
2
2
|
class CacheStats
|
|
3
3
|
def to_h
|
|
4
4
|
{
|
|
5
|
-
entries:
|
|
6
|
-
byte_size:
|
|
5
|
+
entries: ::SolidCache::Entry.count,
|
|
6
|
+
byte_size: ::SolidCache::Entry.sum(:byte_size),
|
|
7
|
+
oldest_entry: ::SolidCache::Entry.minimum(:created_at)
|
|
7
8
|
}
|
|
8
9
|
end
|
|
9
10
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module SolidStackWeb
|
|
2
|
+
class CacheTimeline
|
|
3
|
+
HOURS = 24
|
|
4
|
+
|
|
5
|
+
def entry_buckets
|
|
6
|
+
@entry_buckets ||= build_buckets { |rows, from, to| rows.count { |t, _| t >= from && t < to } }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def byte_buckets
|
|
10
|
+
@byte_buckets ||= build_buckets { |rows, from, to| rows.sum { |t, b| t >= from && t < to ? b : 0 } }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def entry_max
|
|
14
|
+
entry_buckets.max || 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def byte_max
|
|
18
|
+
byte_buckets.max || 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def rows
|
|
24
|
+
@rows ||= begin
|
|
25
|
+
origin = Time.current - HOURS.hours
|
|
26
|
+
::SolidCache::Entry.where(created_at: origin..Time.current).pluck(:created_at, :byte_size)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build_buckets(&block)
|
|
31
|
+
now = Time.current
|
|
32
|
+
origin = now - HOURS.hours
|
|
33
|
+
HOURS.times.map do |i|
|
|
34
|
+
from = origin + i.hours
|
|
35
|
+
to = origin + (i + 1).hours
|
|
36
|
+
block.call(rows, from, to)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Solid Stack Dashboard</title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
7
8
|
<%= csrf_meta_tags %>
|
|
8
9
|
<%= csp_meta_tag %>
|
|
9
10
|
<%= inline_styles %>
|
|
@@ -24,6 +25,26 @@
|
|
|
24
25
|
</div>
|
|
25
26
|
</header>
|
|
26
27
|
|
|
28
|
+
<% if current_section == :cache %>
|
|
29
|
+
<nav class="sqw-subnav">
|
|
30
|
+
<div class="sqw-subnav__inner">
|
|
31
|
+
<%= link_to "Overview", cache_path,
|
|
32
|
+
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cache"}" %>
|
|
33
|
+
<%= link_to "Entries", cache_entries_path,
|
|
34
|
+
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cache_entries"}" %>
|
|
35
|
+
</div>
|
|
36
|
+
</nav>
|
|
37
|
+
<% end %>
|
|
38
|
+
|
|
39
|
+
<% if current_section == :cable %>
|
|
40
|
+
<nav class="sqw-subnav">
|
|
41
|
+
<div class="sqw-subnav__inner">
|
|
42
|
+
<%= link_to "Overview", cable_path,
|
|
43
|
+
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cable"}" %>
|
|
44
|
+
</div>
|
|
45
|
+
</nav>
|
|
46
|
+
<% end %>
|
|
47
|
+
|
|
27
48
|
<% if current_section == :queue %>
|
|
28
49
|
<nav class="sqw-subnav">
|
|
29
50
|
<div class="sqw-subnav__inner">
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
<div class="sqw-page-header">
|
|
1
|
+
<div class="sqw-page-header sqw-page-header--split">
|
|
2
2
|
<h1 class="sqw-page-title">Solid Cable</h1>
|
|
3
|
+
<div class="sqw-header-actions">
|
|
4
|
+
<%= form_with url: cable_purge_path, method: :delete, class: "sqw-inline-form",
|
|
5
|
+
data: { turbo_confirm: "Purge these messages? This cannot be undone." } do |f| %>
|
|
6
|
+
<%= f.select :older_than,
|
|
7
|
+
[["Older than 1 day", 1], ["Older than 7 days", 7], ["Older than 30 days", 30]],
|
|
8
|
+
{}, class: "sqw-select" %>
|
|
9
|
+
<%= f.submit "Purge Old", class: "sqw-btn sqw-btn--danger sqw-btn--sm" %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
3
12
|
</div>
|
|
4
13
|
|
|
5
14
|
<div class="sqw-stats-grid">
|
|
@@ -13,15 +22,53 @@
|
|
|
13
22
|
</div>
|
|
14
23
|
</div>
|
|
15
24
|
|
|
25
|
+
<div class="sqw-timeline-grid sqw-timeline-grid--single" data-controller="sparkline-tooltip">
|
|
26
|
+
<div class="sqw-sparkline-wrap sqw-timeline-chart sqw-timeline-chart--cable">
|
|
27
|
+
<span class="sqw-sparkline-label">Messages — last 24 hours</span>
|
|
28
|
+
<div class="sqw-sparkline-positioner">
|
|
29
|
+
<%= cable_messages_timeline_svg(@timeline) %>
|
|
30
|
+
<div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="sqw-sparkline-axis">
|
|
33
|
+
<span>24h ago</span>
|
|
34
|
+
<span>12h ago</span>
|
|
35
|
+
<span>now</span>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<form class="sqw-filters" action="<%= cable_path %>" method="get" data-controller="search">
|
|
41
|
+
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
|
|
42
|
+
placeholder="Filter by channel…" autocomplete="off" aria-label="Filter by channel"
|
|
43
|
+
data-action="input->search#filter">
|
|
44
|
+
<% if @search.present? %>
|
|
45
|
+
<%= link_to "Clear", cable_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
|
|
46
|
+
<% end %>
|
|
47
|
+
</form>
|
|
48
|
+
|
|
16
49
|
<% if @channels.any? %>
|
|
17
50
|
<table class="sqw-table">
|
|
18
51
|
<thead>
|
|
19
|
-
<tr
|
|
52
|
+
<tr>
|
|
53
|
+
<th>Channel</th>
|
|
54
|
+
<th>Messages</th>
|
|
55
|
+
<th>Last Message</th>
|
|
56
|
+
</tr>
|
|
20
57
|
</thead>
|
|
21
58
|
<tbody>
|
|
22
59
|
<% @channels.each do |channel| %>
|
|
23
|
-
<tr
|
|
60
|
+
<tr>
|
|
61
|
+
<td class="sqw-monospace sqw-truncate" title="<%= channel[:channel] %>">
|
|
62
|
+
<%= link_to channel[:channel], cable_channel_messages_path(channel[:channel_hash]), class: "sqw-link" %>
|
|
63
|
+
</td>
|
|
64
|
+
<td><%= channel[:message_count] %></td>
|
|
65
|
+
<td class="sqw-muted"><%= channel[:last_message_at]&.strftime("%b %d %H:%M") %></td>
|
|
66
|
+
</tr>
|
|
24
67
|
<% end %>
|
|
25
68
|
</tbody>
|
|
26
69
|
</table>
|
|
70
|
+
<% else %>
|
|
71
|
+
<div class="sqw-empty">
|
|
72
|
+
<p><%= @search.present? ? "No channels matching “#{@search}”.".html_safe : "No cable messages." %></p>
|
|
73
|
+
</div>
|
|
27
74
|
<% end %>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<div class="sqw-page-header sqw-page-header--split">
|
|
2
|
+
<h1 class="sqw-page-title sqw-truncate" title="<%= @channel_name %>"><%= @channel_name %></h1>
|
|
3
|
+
<div class="sqw-header-actions">
|
|
4
|
+
<%= button_to "Purge Channel",
|
|
5
|
+
cable_channel_purge_path(params[:channel_hash]),
|
|
6
|
+
method: :delete,
|
|
7
|
+
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
|
|
8
|
+
data: { turbo_confirm: "Delete all messages for this channel? This cannot be undone." } %>
|
|
9
|
+
<%= link_to "← Channels", cable_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<form class="sqw-filters" action="<%= cable_channel_messages_path(params[:channel_hash]) %>" method="get" data-controller="search">
|
|
14
|
+
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
|
|
15
|
+
placeholder="Filter by payload…" autocomplete="off" aria-label="Filter by payload"
|
|
16
|
+
data-action="input->search#filter">
|
|
17
|
+
<% if @search.present? %>
|
|
18
|
+
<%= link_to "Clear", cable_channel_messages_path(params[:channel_hash]), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</form>
|
|
21
|
+
|
|
22
|
+
<% if @messages.any? %>
|
|
23
|
+
<table class="sqw-table">
|
|
24
|
+
<thead>
|
|
25
|
+
<tr>
|
|
26
|
+
<th>ID</th>
|
|
27
|
+
<th>Payload</th>
|
|
28
|
+
<th>Sent</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
<% @messages.each do |message| %>
|
|
33
|
+
<tr>
|
|
34
|
+
<td class="sqw-muted sqw-monospace"><%= message.id %></td>
|
|
35
|
+
<td class="sqw-monospace sqw-truncate" title="<%= message.payload %>">
|
|
36
|
+
<%= truncate(message.payload.to_s, length: 120) %>
|
|
37
|
+
</td>
|
|
38
|
+
<td class="sqw-muted" title="<%= message.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %>">
|
|
39
|
+
<%= time_ago_in_words(message.created_at) %> ago
|
|
40
|
+
</td>
|
|
41
|
+
</tr>
|
|
42
|
+
<% end %>
|
|
43
|
+
</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
<%== @pagy.series_nav if @pagy.pages > 1 %>
|
|
46
|
+
<% else %>
|
|
47
|
+
<div class="sqw-empty">
|
|
48
|
+
<p><%= @search.present? ? "No messages matching “#{@search}”.".html_safe : "No messages for this channel." %></p>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
@@ -3,12 +3,94 @@
|
|
|
3
3
|
</div>
|
|
4
4
|
|
|
5
5
|
<div class="sqw-stats-grid">
|
|
6
|
-
|
|
6
|
+
<%= link_to cache_entries_path, class: "sqw-stat sqw-stat--cache" do %>
|
|
7
7
|
<span class="sqw-stat__label">Total Entries</span>
|
|
8
8
|
<span class="sqw-stat__value"><%= @total_entries %></span>
|
|
9
|
-
|
|
9
|
+
<% end %>
|
|
10
10
|
<div class="sqw-stat sqw-stat--cache">
|
|
11
11
|
<span class="sqw-stat__label">Total Size</span>
|
|
12
12
|
<span class="sqw-stat__value"><%= number_to_human_size(@total_byte_size) %></span>
|
|
13
13
|
</div>
|
|
14
14
|
</div>
|
|
15
|
+
|
|
16
|
+
<% if @total_entries > 0 %>
|
|
17
|
+
<div class="sqw-size-grid">
|
|
18
|
+
<section class="sqw-size-section">
|
|
19
|
+
<h2 class="sqw-section-title">Size Distribution</h2>
|
|
20
|
+
<table class="sqw-table">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr>
|
|
23
|
+
<th>Range</th>
|
|
24
|
+
<th>Entries</th>
|
|
25
|
+
<th></th>
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody>
|
|
29
|
+
<% @size_stats.buckets.each do |bucket| %>
|
|
30
|
+
<% pct = @size_stats.total > 0 ? (bucket[:count].to_f / @size_stats.total * 100).round(1) : 0 %>
|
|
31
|
+
<tr>
|
|
32
|
+
<td class="sqw-muted"><%=bucket[:label] %></td>
|
|
33
|
+
<td><%= bucket[:count] %></td>
|
|
34
|
+
<td>
|
|
35
|
+
<div class="sqw-dist-bar-cell">
|
|
36
|
+
<div class="sqw-dist-bar" style="width: <%= pct %>%"></div>
|
|
37
|
+
<span class="sqw-dist-pct sqw-muted"><%= pct %>%</span>
|
|
38
|
+
</div>
|
|
39
|
+
</td>
|
|
40
|
+
</tr>
|
|
41
|
+
<% end %>
|
|
42
|
+
</tbody>
|
|
43
|
+
</table>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section class="sqw-size-section">
|
|
47
|
+
<h2 class="sqw-section-title">Largest Entries</h2>
|
|
48
|
+
<table class="sqw-table">
|
|
49
|
+
<thead>
|
|
50
|
+
<tr>
|
|
51
|
+
<th>Key</th>
|
|
52
|
+
<th>Size</th>
|
|
53
|
+
</tr>
|
|
54
|
+
</thead>
|
|
55
|
+
<tbody>
|
|
56
|
+
<% @size_stats.top_entries.each do |entry| %>
|
|
57
|
+
<tr>
|
|
58
|
+
<td class="sqw-monospace sqw-truncate" title="<%= entry.key %>">
|
|
59
|
+
<%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
|
|
60
|
+
</td>
|
|
61
|
+
<td><%= number_to_human_size(entry.byte_size) %></td>
|
|
62
|
+
</tr>
|
|
63
|
+
<% end %>
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
</section>
|
|
67
|
+
</div>
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
70
|
+
<div class="sqw-timeline-grid" data-controller="sparkline-tooltip">
|
|
71
|
+
<div class="sqw-sparkline-wrap sqw-timeline-chart">
|
|
72
|
+
<span class="sqw-sparkline-label">Entries written — last 24 hours</span>
|
|
73
|
+
<div class="sqw-sparkline-positioner">
|
|
74
|
+
<%= cache_entries_timeline_svg(@timeline) %>
|
|
75
|
+
<div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="sqw-sparkline-axis">
|
|
78
|
+
<span>24h ago</span>
|
|
79
|
+
<span>12h ago</span>
|
|
80
|
+
<span>now</span>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div class="sqw-sparkline-wrap sqw-timeline-chart">
|
|
85
|
+
<span class="sqw-sparkline-label">Bytes written — last 24 hours</span>
|
|
86
|
+
<div class="sqw-sparkline-positioner">
|
|
87
|
+
<%= cache_bytes_timeline_svg(@timeline) %>
|
|
88
|
+
<div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="sqw-sparkline-axis">
|
|
91
|
+
<span>24h ago</span>
|
|
92
|
+
<span>12h ago</span>
|
|
93
|
+
<span>now</span>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<div class="sqw-page-header sqw-page-header--split">
|
|
2
|
+
<h1 class="sqw-page-title">Cache Entries</h1>
|
|
3
|
+
<div class="sqw-header-actions">
|
|
4
|
+
<%= button_to "Flush All",
|
|
5
|
+
cache_flush_path,
|
|
6
|
+
method: :delete,
|
|
7
|
+
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
|
|
8
|
+
data: { turbo_confirm: "Delete all cache entries? This cannot be undone." } %>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<form class="sqw-filters" action="<%= cache_entries_path %>" method="get" data-controller="search">
|
|
13
|
+
<%= hidden_field_tag :column, @sort["column"] %>
|
|
14
|
+
<%= hidden_field_tag :direction, @sort["direction"] %>
|
|
15
|
+
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
|
|
16
|
+
placeholder="Filter by key…" autocomplete="off" aria-label="Filter by key"
|
|
17
|
+
data-action="input->search#filter">
|
|
18
|
+
<% if @search.present? %>
|
|
19
|
+
<%= link_to "Clear", cache_entries_path(column: @sort["column"], direction: @sort["direction"]),
|
|
20
|
+
class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</form>
|
|
23
|
+
|
|
24
|
+
<% if @entries.any? %>
|
|
25
|
+
<table class="sqw-table">
|
|
26
|
+
<thead>
|
|
27
|
+
<tr>
|
|
28
|
+
<% [["key", "Key"], ["byte_size", "Size"], ["created_at", "Created"]].each do |col, label| %>
|
|
29
|
+
<th>
|
|
30
|
+
<% next_dir = (@sort["column"] == col && @sort["direction"] == "desc") ? "asc" : "desc" %>
|
|
31
|
+
<%= link_to cache_entries_path(q: @search, column: col, direction: next_dir) do %>
|
|
32
|
+
<%= label %>
|
|
33
|
+
<% if @sort["column"] == col %>
|
|
34
|
+
<span class="sqw-sort-indicator"><%= @sort["direction"] == "desc" ? "↓" : "↑" %></span>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
37
|
+
</th>
|
|
38
|
+
<% end %>
|
|
39
|
+
<th></th>
|
|
40
|
+
</tr>
|
|
41
|
+
</thead>
|
|
42
|
+
<tbody>
|
|
43
|
+
<% @entries.each do |entry| %>
|
|
44
|
+
<tr id="cache_entry_<%= entry.id %>">
|
|
45
|
+
<td class="sqw-monospace sqw-truncate" title="<%= entry.key %>">
|
|
46
|
+
<%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
|
|
47
|
+
</td>
|
|
48
|
+
<td><%= number_to_human_size(entry.byte_size) %></td>
|
|
49
|
+
<td class="sqw-muted"><%= entry.created_at.strftime("%b %d %H:%M") %></td>
|
|
50
|
+
<td class="sqw-actions">
|
|
51
|
+
<%= button_to "Delete",
|
|
52
|
+
cache_entry_path(entry, q: @search, column: @sort["column"], direction: @sort["direction"]),
|
|
53
|
+
method: :delete,
|
|
54
|
+
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
|
|
55
|
+
data: { turbo_confirm: "Delete this cache entry?" } %>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>
|
|
58
|
+
<% end %>
|
|
59
|
+
</tbody>
|
|
60
|
+
</table>
|
|
61
|
+
<%== @pagy.series_nav if @pagy.pages > 1 %>
|
|
62
|
+
<% else %>
|
|
63
|
+
<div class="sqw-empty">
|
|
64
|
+
<p><%= @search.present? ? "No entries matching “#{@search}”.".html_safe : "No cache entries." %></p>
|
|
65
|
+
</div>
|
|
66
|
+
<% end %>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<div class="sqw-page-header sqw-page-header--split">
|
|
2
|
+
<h1 class="sqw-page-title sqw-truncate" title="<%= @entry.key %>"><%= @entry.key %></h1>
|
|
3
|
+
<div class="sqw-header-actions">
|
|
4
|
+
<%= button_to "Delete",
|
|
5
|
+
cache_entry_path(@entry),
|
|
6
|
+
method: :delete,
|
|
7
|
+
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
|
|
8
|
+
data: { turbo_confirm: "Delete this cache entry?" } %>
|
|
9
|
+
<%= link_to "← Entries", cache_entries_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<dl class="sqw-detail">
|
|
14
|
+
<div class="sqw-detail__row">
|
|
15
|
+
<dt>Key</dt>
|
|
16
|
+
<dd class="sqw-monospace"><%= @entry.key %></dd>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="sqw-detail__row">
|
|
19
|
+
<dt>Size</dt>
|
|
20
|
+
<dd><%= number_to_human_size(@entry.byte_size) %></dd>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="sqw-detail__row">
|
|
23
|
+
<dt>Created</dt>
|
|
24
|
+
<dd class="sqw-muted"><%= @entry.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %></dd>
|
|
25
|
+
</div>
|
|
26
|
+
</dl>
|
|
27
|
+
|
|
28
|
+
<section class="sqw-value-preview">
|
|
29
|
+
<div class="sqw-value-preview__header">
|
|
30
|
+
<h2 class="sqw-section-title">Value</h2>
|
|
31
|
+
<% if SolidStackWeb.allow_value_preview %>
|
|
32
|
+
<% formatted = format_cache_value(@entry.value) %>
|
|
33
|
+
<span class="sqw-badge sqw-badge--queue"><%= formatted[:label] %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% if SolidStackWeb.allow_value_preview %>
|
|
37
|
+
<% content = formatted[:content] %>
|
|
38
|
+
<% truncated = content.length > 4096 %>
|
|
39
|
+
<pre class="sqw-value-pre"><%= truncated ? content[0, 4096] : content %></pre>
|
|
40
|
+
<% if truncated %>
|
|
41
|
+
<p class="sqw-muted sqw-value-truncated">Showing first 4 KB of <%= number_to_human_size(@entry.byte_size) %> total.</p>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% else %>
|
|
44
|
+
<div class="sqw-empty">
|
|
45
|
+
<p>Value preview is disabled. Set <code>config.allow_value_preview = true</code> in your initializer to enable.</p>
|
|
46
|
+
</div>
|
|
47
|
+
<% end %>
|
|
48
|
+
</section>
|
|
@@ -76,14 +76,20 @@
|
|
|
76
76
|
<%= link_to "View Cache →", cache_path, class: "sqw-gem-card__link" %>
|
|
77
77
|
</div>
|
|
78
78
|
<div class="sqw-gem-card__body">
|
|
79
|
-
|
|
79
|
+
<%= link_to cache_entries_path, class: "sqw-inline-stat sqw-inline-stat--cache" do %>
|
|
80
80
|
<span class="sqw-inline-stat__label">Entries</span>
|
|
81
81
|
<span class="sqw-inline-stat__value"><%= @cache_stats[:entries] %></span>
|
|
82
|
-
|
|
82
|
+
<% end %>
|
|
83
83
|
<div class="sqw-inline-stat sqw-inline-stat--cache">
|
|
84
84
|
<span class="sqw-inline-stat__label">Size</span>
|
|
85
85
|
<span class="sqw-inline-stat__value"><%= number_to_human_size(@cache_stats[:byte_size]) %></span>
|
|
86
86
|
</div>
|
|
87
|
+
<% if @cache_stats[:oldest_entry] %>
|
|
88
|
+
<div class="sqw-inline-stat sqw-inline-stat--cache">
|
|
89
|
+
<span class="sqw-inline-stat__label">Oldest</span>
|
|
90
|
+
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm" title="<%= @cache_stats[:oldest_entry].strftime("%b %d, %Y %H:%M") %>"><%= time_ago_in_words(@cache_stats[:oldest_entry]) %></span>
|
|
91
|
+
</div>
|
|
92
|
+
<% end %>
|
|
87
93
|
</div>
|
|
88
94
|
</div>
|
|
89
95
|
|
|
@@ -93,15 +99,36 @@
|
|
|
93
99
|
<%= link_to "View Cable →", cable_path, class: "sqw-gem-card__link" %>
|
|
94
100
|
</div>
|
|
95
101
|
<div class="sqw-gem-card__body">
|
|
96
|
-
|
|
102
|
+
<%= link_to cable_path, class: "sqw-inline-stat sqw-inline-stat--cable" do %>
|
|
97
103
|
<span class="sqw-inline-stat__label">Messages</span>
|
|
98
104
|
<span class="sqw-inline-stat__value"><%= @cable_stats[:messages] %></span>
|
|
99
|
-
|
|
105
|
+
<% end %>
|
|
100
106
|
<div class="sqw-inline-stat sqw-inline-stat--cable">
|
|
101
107
|
<span class="sqw-inline-stat__label">Channels</span>
|
|
102
108
|
<span class="sqw-inline-stat__value"><%= @cable_stats[:channels] %></span>
|
|
103
109
|
</div>
|
|
110
|
+
<div class="sqw-inline-stat sqw-inline-stat--cable">
|
|
111
|
+
<span class="sqw-inline-stat__label">Msg/hr</span>
|
|
112
|
+
<span class="sqw-inline-stat__value"><%= @cable_stats[:messages_per_hour] %></span>
|
|
113
|
+
</div>
|
|
114
|
+
<% if @cable_stats[:oldest_message] %>
|
|
115
|
+
<div class="sqw-inline-stat sqw-inline-stat--cable">
|
|
116
|
+
<span class="sqw-inline-stat__label">Oldest</span>
|
|
117
|
+
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm" title="<%= @cable_stats[:oldest_message].strftime("%b %d, %Y %H:%M") %>"><%= time_ago_in_words(@cable_stats[:oldest_message]) %></span>
|
|
118
|
+
</div>
|
|
119
|
+
<% end %>
|
|
104
120
|
</div>
|
|
121
|
+
<% if @cable_stats[:top_channels].any? %>
|
|
122
|
+
<div class="sqw-card-list">
|
|
123
|
+
<span class="sqw-card-list__label">Top channels</span>
|
|
124
|
+
<% @cable_stats[:top_channels].each do |channel, count| %>
|
|
125
|
+
<div class="sqw-card-list__row">
|
|
126
|
+
<span class="sqw-card-list__name" title="<%= channel %>"><%= channel %></span>
|
|
127
|
+
<span class="sqw-card-list__count"><%= count %></span>
|
|
128
|
+
</div>
|
|
129
|
+
<% end %>
|
|
130
|
+
</div>
|
|
131
|
+
<% end %>
|
|
105
132
|
</div>
|
|
106
133
|
</div>
|
|
107
|
-
<% end %>
|
|
134
|
+
<% end %>
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
</div>
|
|
11
11
|
</div>
|
|
12
12
|
|
|
13
|
-
<form class="sqw-filters" action="<%= history_path %>" method="get">
|
|
13
|
+
<form class="sqw-filters" action="<%= history_path %>" method="get" data-controller="search">
|
|
14
14
|
<% if @queue.present? %>
|
|
15
15
|
<input type="hidden" name="queue" value="<%= @queue %>">
|
|
16
16
|
<% end %>
|
|
17
17
|
<input type="hidden" name="period" value="<%= @period %>">
|
|
18
18
|
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
|
|
19
|
-
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
|
|
20
|
-
|
|
19
|
+
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
|
|
20
|
+
data-action="input->search#filter">
|
|
21
21
|
<% if @search.present? %>
|
|
22
22
|
<%= link_to "Clear", history_path(queue: @queue, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
|
|
23
23
|
<% end %>
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
<% end %>
|
|
67
67
|
</tbody>
|
|
68
68
|
</table>
|
|
69
|
-
<%==
|
|
69
|
+
<%== @pagy.series_nav if @pagy.pages > 1 %>
|
|
70
70
|
</div>
|
|
71
71
|
<% else %>
|
|
72
72
|
<div class="sqw-empty">
|
|
@@ -32,13 +32,15 @@
|
|
|
32
32
|
<%= turbo_frame_tag "sqw-jobs-filter",
|
|
33
33
|
data: { turbo_action: "advance", controller: "refresh",
|
|
34
34
|
refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
|
|
35
|
-
<form class="sqw-filters" action="<%= jobs_path %>" method="get">
|
|
35
|
+
<form class="sqw-filters" action="<%= jobs_path %>" method="get" data-controller="search">
|
|
36
36
|
<%= hidden_field_tag :status, @status %>
|
|
37
37
|
<%= hidden_field_tag :period, @period %>
|
|
38
38
|
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
|
|
39
|
-
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
|
|
39
|
+
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
|
|
40
|
+
data-action="input->search#filter">
|
|
40
41
|
<% if @queue_options.size > 1 %>
|
|
41
|
-
<select name="queue" class="sqw-select" aria-label="Filter by queue"
|
|
42
|
+
<select name="queue" class="sqw-select" aria-label="Filter by queue"
|
|
43
|
+
data-action="change->search#select">
|
|
42
44
|
<option value="">All queues</option>
|
|
43
45
|
<% @queue_options.each do |q| %>
|
|
44
46
|
<option value="<%= q %>" <%= "selected" if @queue == q %>><%= q %></option>
|
|
@@ -46,14 +48,14 @@
|
|
|
46
48
|
</select>
|
|
47
49
|
<% end %>
|
|
48
50
|
<% if @priority_options.size > 1 %>
|
|
49
|
-
<select name="priority" class="sqw-select" aria-label="Filter by priority"
|
|
51
|
+
<select name="priority" class="sqw-select" aria-label="Filter by priority"
|
|
52
|
+
data-action="change->search#select">
|
|
50
53
|
<option value="">All priorities</option>
|
|
51
54
|
<% @priority_options.each do |p| %>
|
|
52
55
|
<option value="<%= p %>" <%= "selected" if @priority.to_s == p.to_s %>>Priority <%= p %></option>
|
|
53
56
|
<% end %>
|
|
54
57
|
</select>
|
|
55
58
|
<% end %>
|
|
56
|
-
<button type="submit" class="sqw-btn sqw-btn--muted sqw-btn--sm">Search</button>
|
|
57
59
|
<% if @search.present? || @queue.present? || @priority.present? %>
|
|
58
60
|
<%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
|
|
59
61
|
<% end %>
|
|
@@ -147,7 +149,7 @@
|
|
|
147
149
|
<% end %>
|
|
148
150
|
</tbody>
|
|
149
151
|
</table>
|
|
150
|
-
<%==
|
|
152
|
+
<%== @pagy.series_nav if @pagy.pages > 1 %>
|
|
151
153
|
</div>
|
|
152
154
|
<% else %>
|
|
153
155
|
<%= render "empty" %>
|
data/config/importmap.rb
CHANGED
|
@@ -2,5 +2,6 @@ pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/
|
|
|
2
2
|
pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
|
|
3
3
|
pin "solid_stack_web", to: "solid_stack_web/application.js"
|
|
4
4
|
pin "solid_stack_web/refresh_controller", to: "solid_stack_web/refresh_controller.js"
|
|
5
|
+
pin "solid_stack_web/search_controller", to: "solid_stack_web/search_controller.js"
|
|
5
6
|
pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
|
|
6
7
|
pin "solid_stack_web/sparkline_tooltip_controller", to: "solid_stack_web/sparkline_tooltip_controller.js"
|
data/config/routes.rb
CHANGED
|
@@ -35,5 +35,10 @@ SolidStackWeb::Engine.routes.draw do
|
|
|
35
35
|
get "stats", to: "stats#index", as: :stats
|
|
36
36
|
get "history", to: "history#index", as: :history
|
|
37
37
|
get "cache", to: "cache#index", as: :cache
|
|
38
|
-
|
|
38
|
+
resources :cache_entries, only: [:index, :show, :destroy], path: "cache/entries"
|
|
39
|
+
resource :cache_flush, only: [:destroy], path: "cache/flush", controller: "cache/flushes"
|
|
40
|
+
get "cable", to: "cable#index", as: :cable
|
|
41
|
+
delete "cable/purge", to: "cable/purges#destroy", as: :cable_purge
|
|
42
|
+
get "cable/channels/:channel_hash", to: "cable_messages#index", as: :cable_channel_messages
|
|
43
|
+
delete "cable/channels/:channel_hash/purge", to: "cable/channel_purges#destroy", as: :cable_channel_purge
|
|
39
44
|
end
|
data/lib/solid_stack_web.rb
CHANGED
|
@@ -7,7 +7,7 @@ module SolidStackWeb
|
|
|
7
7
|
:alert_webhook_url, :alert_webhook_cooldown,
|
|
8
8
|
:alert_failure_threshold, :alert_queue_thresholds,
|
|
9
9
|
:dashboard_refresh_interval, :default_refresh_interval,
|
|
10
|
-
:search_results_limit
|
|
10
|
+
:search_results_limit, :allow_value_preview
|
|
11
11
|
|
|
12
12
|
def page_size
|
|
13
13
|
@page_size || 25
|
|
@@ -49,6 +49,10 @@ module SolidStackWeb
|
|
|
49
49
|
@search_results_limit || 25
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
def allow_value_preview
|
|
53
|
+
@allow_value_preview || false
|
|
54
|
+
end
|
|
55
|
+
|
|
52
56
|
def configure
|
|
53
57
|
yield self
|
|
54
58
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_stack_web
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -144,8 +144,13 @@ files:
|
|
|
144
144
|
- app/assets/stylesheets/solid_stack_web/_09_detail.css
|
|
145
145
|
- app/assets/stylesheets/solid_stack_web/application.css
|
|
146
146
|
- app/controllers/solid_stack_web/application_controller.rb
|
|
147
|
+
- app/controllers/solid_stack_web/cable/channel_purges_controller.rb
|
|
148
|
+
- app/controllers/solid_stack_web/cable/purges_controller.rb
|
|
147
149
|
- app/controllers/solid_stack_web/cable_controller.rb
|
|
150
|
+
- app/controllers/solid_stack_web/cable_messages_controller.rb
|
|
151
|
+
- app/controllers/solid_stack_web/cache/flushes_controller.rb
|
|
148
152
|
- app/controllers/solid_stack_web/cache_controller.rb
|
|
153
|
+
- app/controllers/solid_stack_web/cache_entries_controller.rb
|
|
149
154
|
- app/controllers/solid_stack_web/dashboard_controller.rb
|
|
150
155
|
- app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb
|
|
151
156
|
- app/controllers/solid_stack_web/failed_jobs/selections_controller.rb
|
|
@@ -164,18 +169,25 @@ files:
|
|
|
164
169
|
- app/helpers/solid_stack_web/application_helper.rb
|
|
165
170
|
- app/javascript/solid_stack_web/application.js
|
|
166
171
|
- app/javascript/solid_stack_web/refresh_controller.js
|
|
172
|
+
- app/javascript/solid_stack_web/search_controller.js
|
|
167
173
|
- app/javascript/solid_stack_web/selection_controller.js
|
|
168
174
|
- app/javascript/solid_stack_web/sparkline_tooltip_controller.js
|
|
169
175
|
- app/models/solid_stack_web/alert_webhook.rb
|
|
170
176
|
- app/models/solid_stack_web/cable_stats.rb
|
|
177
|
+
- app/models/solid_stack_web/cable_timeline.rb
|
|
178
|
+
- app/models/solid_stack_web/cache_size_stats.rb
|
|
171
179
|
- app/models/solid_stack_web/cache_stats.rb
|
|
180
|
+
- app/models/solid_stack_web/cache_timeline.rb
|
|
172
181
|
- app/models/solid_stack_web/job.rb
|
|
173
182
|
- app/models/solid_stack_web/queue_depth_sparkline.rb
|
|
174
183
|
- app/models/solid_stack_web/queue_stats.rb
|
|
175
184
|
- app/models/solid_stack_web/throughput_sparkline.rb
|
|
176
185
|
- app/views/layouts/solid_stack_web/application.html.erb
|
|
177
186
|
- app/views/solid_stack_web/cable/index.html.erb
|
|
187
|
+
- app/views/solid_stack_web/cable_messages/index.html.erb
|
|
178
188
|
- app/views/solid_stack_web/cache/index.html.erb
|
|
189
|
+
- app/views/solid_stack_web/cache_entries/index.html.erb
|
|
190
|
+
- app/views/solid_stack_web/cache_entries/show.html.erb
|
|
179
191
|
- app/views/solid_stack_web/dashboard/index.html.erb
|
|
180
192
|
- app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb
|
|
181
193
|
- app/views/solid_stack_web/failed_jobs/index.html.erb
|
|
@@ -218,7 +230,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
218
230
|
- !ruby/object:Gem::Version
|
|
219
231
|
version: '0'
|
|
220
232
|
requirements: []
|
|
221
|
-
rubygems_version: 4.0.
|
|
233
|
+
rubygems_version: 4.0.10
|
|
222
234
|
specification_version: 4
|
|
223
235
|
summary: A unified Rails engine dashboard for Solid Queue, Solid Cache, and Solid
|
|
224
236
|
Cable.
|