solid_queue_monitor 1.2.1 → 1.3.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 +19 -0
- data/app/controllers/solid_queue_monitor/base_controller.rb +2 -1
- data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +2 -1
- data/app/controllers/solid_queue_monitor/jobs_controller.rb +2 -1
- data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +2 -1
- data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +68 -155
- data/app/presenters/solid_queue_monitor/job_details_presenter.rb +52 -41
- data/app/presenters/solid_queue_monitor/jobs_presenter.rb +1 -1
- data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +2 -2
- data/app/presenters/solid_queue_monitor/queues_presenter.rb +1 -1
- data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +7 -2
- data/app/presenters/solid_queue_monitor/workers_presenter.rb +8 -3
- data/app/services/solid_queue_monitor/chart_data_service.rb +3 -3
- data/app/services/solid_queue_monitor/chart_presenter.rb +5 -5
- data/app/services/solid_queue_monitor/html_generator.rb +53 -27
- data/app/services/solid_queue_monitor/stylesheet_generator.rb +57 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03dcf6baf00328ad18dadaaf499e82d178d7e62c7cc01433f48a1e222c245742
|
|
4
|
+
data.tar.gz: 15172ad03509c92f8d86b1c06cc78695203f125576838796d15c644c3b4a7eb0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b973471c7ac561b9b527dbb710610b41d452325fa7edfbd58f57f0454cd1d78a932e645854b12cbfc37b02b56f36006a1fe4fc243e23b03635bd6fef2e0baff
|
|
7
|
+
data.tar.gz: 56b6b3f15cbccedbee9782cd70efe8c1c2b75b47fba7d5b4e38535a3c17386a7f355a295a44de7305efd84c2aeefc39516535d885e3f9361960564202cd2dfba
|
data/README.md
CHANGED
|
@@ -206,6 +206,25 @@ This makes it easy to find specific jobs when debugging issues in your applicati
|
|
|
206
206
|
- **Rails**: 7.1 or higher
|
|
207
207
|
- **Solid Queue**: 0.1.0 or higher
|
|
208
208
|
|
|
209
|
+
## Content Security Policy
|
|
210
|
+
|
|
211
|
+
Solid Queue Monitor is compatible with strict Content Security Policy as of v1.3.0.
|
|
212
|
+
|
|
213
|
+
If your application uses nonce-based CSP (the Rails default when `content_security_policy_nonce_generator` is set), Solid Queue Monitor will automatically stamp the per-request nonce onto every inline `<style>` and `<script>` tag it emits. Ensure your nonce directives include both `script-src` and `style-src`:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
# config/initializers/content_security_policy.rb
|
|
217
|
+
Rails.application.config.content_security_policy do |policy|
|
|
218
|
+
policy.script_src :self
|
|
219
|
+
policy.style_src :self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
Rails.application.config.content_security_policy_nonce_generator = ->(req) { SecureRandom.base64(16) }
|
|
223
|
+
Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src]
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
No other configuration is required. If your application runs CSP without nonces (e.g., strict `script-src 'self'` only), the monitor UI will not function — asset-extraction support is tracked for a future release.
|
|
227
|
+
|
|
209
228
|
## Contributing
|
|
210
229
|
|
|
211
230
|
Contributions are welcome! Here's how you can contribute:
|
|
@@ -13,7 +13,8 @@ module SolidQueueMonitor
|
|
|
13
13
|
current_page: @failed_jobs[:current_page],
|
|
14
14
|
total_pages: @failed_jobs[:total_pages],
|
|
15
15
|
filters: filter_params,
|
|
16
|
-
sort: sort_params
|
|
16
|
+
sort: sort_params,
|
|
17
|
+
nonce: content_security_policy_nonce).render)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def retry
|
|
@@ -13,7 +13,8 @@ module SolidQueueMonitor
|
|
|
13
13
|
current_page: @scheduled_jobs[:current_page],
|
|
14
14
|
total_pages: @scheduled_jobs[:total_pages],
|
|
15
15
|
filters: filter_params,
|
|
16
|
-
sort: sort_params
|
|
16
|
+
sort: sort_params,
|
|
17
|
+
nonce: content_security_policy_nonce).render)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def create
|
|
@@ -5,12 +5,13 @@ module SolidQueueMonitor
|
|
|
5
5
|
include Rails.application.routes.url_helpers
|
|
6
6
|
include SolidQueueMonitor::Engine.routes.url_helpers
|
|
7
7
|
|
|
8
|
-
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
|
|
8
|
+
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}, nonce: nil)
|
|
9
9
|
@jobs = jobs
|
|
10
10
|
@current_page = current_page
|
|
11
11
|
@total_pages = total_pages
|
|
12
12
|
@filters = filters
|
|
13
13
|
@sort = sort
|
|
14
|
+
@nonce = nonce
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def render
|
|
@@ -20,6 +21,10 @@ module SolidQueueMonitor
|
|
|
20
21
|
|
|
21
22
|
private
|
|
22
23
|
|
|
24
|
+
def script_tag_open
|
|
25
|
+
@nonce ? %(<script nonce="#{@nonce}">) : '<script>'
|
|
26
|
+
end
|
|
27
|
+
|
|
23
28
|
def generate_filter_form
|
|
24
29
|
<<-HTML
|
|
25
30
|
<div class="filter-form-container">
|
|
@@ -76,170 +81,83 @@ module SolidQueueMonitor
|
|
|
76
81
|
</div>
|
|
77
82
|
</form>
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
#{script_tag_open}
|
|
80
85
|
document.addEventListener('DOMContentLoaded', function() {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
#{' '}
|
|
86
|
+
var selectAllHeader = document.getElementById('select-all');
|
|
87
|
+
var checkboxes = document.querySelectorAll('.job-checkbox');
|
|
88
|
+
var retrySelectedBtn = document.getElementById('retry-selected-top');
|
|
89
|
+
var discardSelectedBtn = document.getElementById('discard-selected-top');
|
|
90
|
+
var form = document.getElementById('failed-jobs-form');
|
|
91
|
+
|
|
88
92
|
function updateButtonState() {
|
|
89
|
-
|
|
93
|
+
var checkedBoxes = document.querySelectorAll('.job-checkbox:checked');
|
|
90
94
|
retrySelectedBtn.disabled = checkedBoxes.length === 0;
|
|
91
95
|
discardSelectedBtn.disabled = checkedBoxes.length === 0;
|
|
92
96
|
}
|
|
93
|
-
|
|
94
|
-
function toggleAll(checked) {
|
|
95
|
-
checkboxes.forEach(checkbox => {
|
|
96
|
-
checkbox.checked = checked;
|
|
97
|
-
});
|
|
98
|
-
selectAllHeader.checked = checked;
|
|
99
|
-
updateButtonState();
|
|
100
|
-
}
|
|
101
|
-
#{' '}
|
|
97
|
+
|
|
102
98
|
selectAllHeader.addEventListener('change', function() {
|
|
103
|
-
|
|
99
|
+
checkboxes.forEach(function(cb) { cb.checked = selectAllHeader.checked; });
|
|
100
|
+
updateButtonState();
|
|
104
101
|
});
|
|
105
|
-
|
|
106
|
-
checkboxes.forEach(
|
|
107
|
-
|
|
102
|
+
|
|
103
|
+
checkboxes.forEach(function(cb) {
|
|
104
|
+
cb.addEventListener('change', function() {
|
|
108
105
|
updateButtonState();
|
|
109
|
-
|
|
110
|
-
// Update select all checkboxes if needed
|
|
111
|
-
const allChecked = document.querySelectorAll('.job-checkbox:checked').length === checkboxes.length;
|
|
106
|
+
var allChecked = document.querySelectorAll('.job-checkbox:checked').length === checkboxes.length;
|
|
112
107
|
selectAllHeader.checked = allChecked;
|
|
113
108
|
});
|
|
114
109
|
});
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
|
|
111
|
+
function bulkSubmit(action, promptMsg) {
|
|
112
|
+
var ids = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(function(cb) { return cb.value; });
|
|
113
|
+
if (ids.length === 0) return;
|
|
114
|
+
if (!confirm(promptMsg)) return;
|
|
115
|
+
form.action = action;
|
|
116
|
+
appendHidden(form, 'redirect_cleanly', 'true');
|
|
117
|
+
ids.forEach(function(id) { appendHidden(form, 'job_ids[]', id); });
|
|
118
|
+
form.submit();
|
|
119
|
+
setTimeout(function() { window.history.replaceState({}, '', window.location.pathname); }, 100);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appendHidden(f, name, value) {
|
|
123
|
+
var input = document.createElement('input');
|
|
124
|
+
input.type = 'hidden';
|
|
125
|
+
input.name = name;
|
|
126
|
+
input.value = value;
|
|
127
|
+
f.appendChild(input);
|
|
128
|
+
}
|
|
129
|
+
|
|
117
130
|
retrySelectedBtn.addEventListener('click', function() {
|
|
118
|
-
|
|
119
|
-
if (selectedIds.length === 0) return;
|
|
120
|
-
#{' '}
|
|
121
|
-
if (confirm('Are you sure you want to retry the selected jobs?')) {
|
|
122
|
-
form.action = '#{retry_failed_jobs_path}';
|
|
123
|
-
#{' '}
|
|
124
|
-
// Add a special flag to indicate this should redirect properly
|
|
125
|
-
const redirectInput = document.createElement('input');
|
|
126
|
-
redirectInput.type = 'hidden';
|
|
127
|
-
redirectInput.name = 'redirect_cleanly';
|
|
128
|
-
redirectInput.value = 'true';
|
|
129
|
-
form.appendChild(redirectInput);
|
|
130
|
-
#{' '}
|
|
131
|
-
// Add selected IDs as hidden inputs
|
|
132
|
-
selectedIds.forEach(id => {
|
|
133
|
-
const input = document.createElement('input');
|
|
134
|
-
input.type = 'hidden';
|
|
135
|
-
input.name = 'job_ids[]';
|
|
136
|
-
input.value = id;
|
|
137
|
-
form.appendChild(input);
|
|
138
|
-
});
|
|
139
|
-
#{' '}
|
|
140
|
-
// Submit the form and then replace the URL location immediately after
|
|
141
|
-
form.submit();
|
|
142
|
-
#{' '}
|
|
143
|
-
// Delay the redirect to give the form time to submit
|
|
144
|
-
setTimeout(function() {
|
|
145
|
-
// Reset to the clean URL without query parameters
|
|
146
|
-
window.history.replaceState({}, '', window.location.pathname);
|
|
147
|
-
}, 100);
|
|
148
|
-
}
|
|
131
|
+
bulkSubmit('#{retry_failed_jobs_path}', 'Are you sure you want to retry the selected jobs?');
|
|
149
132
|
});
|
|
150
|
-
#{' '}
|
|
151
133
|
discardSelectedBtn.addEventListener('click', function() {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
form.submit();
|
|
176
|
-
#{' '}
|
|
177
|
-
// Delay the redirect to give the form time to submit
|
|
178
|
-
setTimeout(function() {
|
|
179
|
-
// Reset to the clean URL without query parameters
|
|
180
|
-
window.history.replaceState({}, '', window.location.pathname);
|
|
181
|
-
}, 100);
|
|
134
|
+
bulkSubmit('#{discard_failed_jobs_path}', 'Are you sure you want to discard the selected jobs?');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
function submitRowAction(action, id) {
|
|
138
|
+
var f = document.createElement('form');
|
|
139
|
+
f.method = 'post';
|
|
140
|
+
f.action = action.replace('PLACEHOLDER', id);
|
|
141
|
+
appendHidden(f, 'redirect_cleanly', 'true');
|
|
142
|
+
document.body.appendChild(f);
|
|
143
|
+
f.submit();
|
|
144
|
+
setTimeout(function() { window.history.replaceState({}, '', window.location.pathname); }, 100);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
document.addEventListener('click', function(e) {
|
|
148
|
+
var btn = e.target.closest('[data-action]');
|
|
149
|
+
if (!btn) return;
|
|
150
|
+
var id = btn.dataset.jobId;
|
|
151
|
+
if (btn.dataset.action === 'retry-failed-job') {
|
|
152
|
+
submitRowAction('#{retry_failed_job_path(id: 'PLACEHOLDER')}', id);
|
|
153
|
+
} else if (btn.dataset.action === 'discard-failed-job') {
|
|
154
|
+
if (confirm('Are you sure you want to discard this job?')) {
|
|
155
|
+
submitRowAction('#{discard_failed_job_path(id: 'PLACEHOLDER')}', id);
|
|
156
|
+
}
|
|
182
157
|
}
|
|
183
158
|
});
|
|
184
|
-
|
|
185
|
-
// Initialize button state
|
|
159
|
+
|
|
186
160
|
updateButtonState();
|
|
187
|
-
#{' '}
|
|
188
|
-
// Global function for retry action
|
|
189
|
-
window.submitRetryForm = function(id) {
|
|
190
|
-
const form = document.createElement('form');
|
|
191
|
-
form.method = 'post';
|
|
192
|
-
form.action = '#{retry_failed_job_path(id: 'PLACEHOLDER')}';
|
|
193
|
-
form.action = form.action.replace('PLACEHOLDER', id);
|
|
194
|
-
form.style.display = 'none';
|
|
195
|
-
#{' '}
|
|
196
|
-
// Add a special flag to indicate this should redirect properly
|
|
197
|
-
const redirectInput = document.createElement('input');
|
|
198
|
-
redirectInput.type = 'hidden';
|
|
199
|
-
redirectInput.name = 'redirect_cleanly';
|
|
200
|
-
redirectInput.value = 'true';
|
|
201
|
-
form.appendChild(redirectInput);
|
|
202
|
-
#{' '}
|
|
203
|
-
document.body.appendChild(form);
|
|
204
|
-
#{' '}
|
|
205
|
-
// Submit the form and then replace the URL location immediately after
|
|
206
|
-
form.submit();
|
|
207
|
-
#{' '}
|
|
208
|
-
// Delay the redirect to give the form time to submit
|
|
209
|
-
setTimeout(function() {
|
|
210
|
-
// Reset to the clean URL without query parameters
|
|
211
|
-
window.history.replaceState({}, '', window.location.pathname);
|
|
212
|
-
}, 100);
|
|
213
|
-
};
|
|
214
|
-
#{' '}
|
|
215
|
-
// Global function for discard action
|
|
216
|
-
window.submitDiscardForm = function(id) {
|
|
217
|
-
if (confirm('Are you sure you want to discard this job?')) {
|
|
218
|
-
const form = document.createElement('form');
|
|
219
|
-
form.method = 'post';
|
|
220
|
-
form.action = '#{discard_failed_job_path(id: 'PLACEHOLDER')}';
|
|
221
|
-
form.action = form.action.replace('PLACEHOLDER', id);
|
|
222
|
-
form.style.display = 'none';
|
|
223
|
-
#{' '}
|
|
224
|
-
// Add a special flag to indicate this should redirect properly
|
|
225
|
-
const redirectInput = document.createElement('input');
|
|
226
|
-
redirectInput.type = 'hidden';
|
|
227
|
-
redirectInput.name = 'redirect_cleanly';
|
|
228
|
-
redirectInput.value = 'true';
|
|
229
|
-
form.appendChild(redirectInput);
|
|
230
|
-
#{' '}
|
|
231
|
-
document.body.appendChild(form);
|
|
232
|
-
#{' '}
|
|
233
|
-
// Submit the form and then replace the URL location immediately after
|
|
234
|
-
form.submit();
|
|
235
|
-
#{' '}
|
|
236
|
-
// Delay the redirect to give the form time to submit
|
|
237
|
-
setTimeout(function() {
|
|
238
|
-
// Reset to the clean URL without query parameters
|
|
239
|
-
window.history.replaceState({}, '', window.location.pathname);
|
|
240
|
-
}, 100);
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
161
|
});
|
|
244
162
|
</script>
|
|
245
163
|
HTML
|
|
@@ -268,13 +186,8 @@ module SolidQueueMonitor
|
|
|
268
186
|
<td>#{format_datetime(failed_execution.created_at)}</td>
|
|
269
187
|
<td class="actions-cell">
|
|
270
188
|
<div class="job-actions">
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
class="action-button retry-button">Retry</a>
|
|
274
|
-
#{' '}
|
|
275
|
-
<a href="javascript:void(0)"#{' '}
|
|
276
|
-
onclick="submitDiscardForm(#{failed_execution.id})"#{' '}
|
|
277
|
-
class="action-button discard-button">Discard</a>
|
|
189
|
+
<button type="button" data-action="retry-failed-job" data-job-id="#{failed_execution.id}" class="action-button retry-button">Retry</button>
|
|
190
|
+
<button type="button" data-action="discard-failed-job" data-job-id="#{failed_execution.id}" class="action-button discard-button">Discard</button>
|
|
278
191
|
</div>
|
|
279
192
|
</td>
|
|
280
193
|
</tr>
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
module SolidQueueMonitor
|
|
4
4
|
class JobDetailsPresenter < BasePresenter
|
|
5
5
|
def initialize(job, failed_execution: nil, claimed_execution: nil, scheduled_execution: nil,
|
|
6
|
-
recent_executions: [], back_path: nil)
|
|
6
|
+
recent_executions: [], back_path: nil, nonce: nil)
|
|
7
7
|
@job = job
|
|
8
8
|
@failed_execution = failed_execution
|
|
9
9
|
@claimed_execution = claimed_execution
|
|
10
10
|
@scheduled_execution = scheduled_execution
|
|
11
11
|
@recent_executions = recent_executions
|
|
12
12
|
@back_path = back_path
|
|
13
|
+
@nonce = nonce
|
|
13
14
|
calculate_timing
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -32,6 +33,10 @@ module SolidQueueMonitor
|
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
36
|
+
def script_tag_open
|
|
37
|
+
@nonce ? %(<script nonce="#{@nonce}">) : '<script>'
|
|
38
|
+
end
|
|
39
|
+
|
|
35
40
|
def calculate_timing
|
|
36
41
|
@created_at = @job.created_at
|
|
37
42
|
@scheduled_at = @job.scheduled_at || @scheduled_execution&.scheduled_at
|
|
@@ -139,7 +144,7 @@ module SolidQueueMonitor
|
|
|
139
144
|
|
|
140
145
|
actions << <<-HTML
|
|
141
146
|
<form action="#{discard_failed_job_path(id: @failed_execution.id)}" method="post" class="inline-form"
|
|
142
|
-
|
|
147
|
+
data-confirm="Are you sure you want to discard this job?">
|
|
143
148
|
<input type="hidden" name="redirect_to" value="#{failed_jobs_path}">
|
|
144
149
|
<button type="submit" class="action-button discard-button">Discard</button>
|
|
145
150
|
</form>
|
|
@@ -301,7 +306,7 @@ module SolidQueueMonitor
|
|
|
301
306
|
<div class="job-section error-section">
|
|
302
307
|
<div class="section-header">
|
|
303
308
|
<h3 class="section-title">Error</h3>
|
|
304
|
-
<button class="copy-button"
|
|
309
|
+
<button class="copy-button" data-action="copy" data-target="error-content">
|
|
305
310
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
306
311
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
307
312
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
@@ -329,22 +334,13 @@ module SolidQueueMonitor
|
|
|
329
334
|
<div class="backtrace-header">
|
|
330
335
|
<span class="backtrace-title">Backtrace</span>
|
|
331
336
|
<div class="backtrace-toggle">
|
|
332
|
-
<button class="toggle-btn active" data-
|
|
333
|
-
<button class="toggle-btn" data-
|
|
337
|
+
<button class="toggle-btn active" data-action="show-backtrace" data-backtrace="app">App Only</button>
|
|
338
|
+
<button class="toggle-btn" data-action="show-backtrace" data-backtrace="full">Full</button>
|
|
334
339
|
</div>
|
|
335
340
|
</div>
|
|
336
341
|
<pre class="backtrace-content" id="app-backtrace">#{format_backtrace_lines(app_lines.presence || lines.first(5))}</pre>
|
|
337
|
-
<pre class="backtrace-content" id="full-backtrace"
|
|
342
|
+
<pre class="backtrace-content is-hidden" id="full-backtrace">#{format_backtrace_lines(lines)}</pre>
|
|
338
343
|
</div>
|
|
339
|
-
<script>
|
|
340
|
-
function showBacktrace(type) {
|
|
341
|
-
document.getElementById('app-backtrace').style.display = type === 'app' ? 'block' : 'none';
|
|
342
|
-
document.getElementById('full-backtrace').style.display = type === 'full' ? 'block' : 'none';
|
|
343
|
-
document.querySelectorAll('.backtrace-toggle .toggle-btn').forEach(btn => {
|
|
344
|
-
btn.classList.toggle('active', btn.dataset.target === type + '-backtrace');
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
</script>
|
|
348
344
|
HTML
|
|
349
345
|
end
|
|
350
346
|
|
|
@@ -426,7 +422,7 @@ module SolidQueueMonitor
|
|
|
426
422
|
<div class="section-header">
|
|
427
423
|
<h3 class="section-title">Arguments</h3>
|
|
428
424
|
<div class="section-actions">
|
|
429
|
-
<button class="copy-button"
|
|
425
|
+
<button class="copy-button" data-action="copy" data-target="arguments-content">
|
|
430
426
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
431
427
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
432
428
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
@@ -647,14 +643,14 @@ module SolidQueueMonitor
|
|
|
647
643
|
def render_raw_data_section
|
|
648
644
|
<<-HTML
|
|
649
645
|
<div class="job-section collapsible-section">
|
|
650
|
-
<div class="section-header collapsible-header"
|
|
646
|
+
<div class="section-header collapsible-header" data-action="toggle-section">
|
|
651
647
|
<div class="collapsible-title">
|
|
652
648
|
<svg class="collapse-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
653
649
|
<polyline points="9 18 15 12 9 6"></polyline>
|
|
654
650
|
</svg>
|
|
655
651
|
<h3 class="section-title">Raw Data</h3>
|
|
656
652
|
</div>
|
|
657
|
-
<button class="copy-button"
|
|
653
|
+
<button class="copy-button" data-action="copy" data-target="raw-data-content" data-stop-propagation="true">
|
|
658
654
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
659
655
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
660
656
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
@@ -662,33 +658,48 @@ module SolidQueueMonitor
|
|
|
662
658
|
Copy
|
|
663
659
|
</button>
|
|
664
660
|
</div>
|
|
665
|
-
<div class="collapsible-content"
|
|
661
|
+
<div class="collapsible-content">
|
|
666
662
|
<pre class="raw-data-content" id="raw-data-content">#{CGI.escapeHTML(JSON.pretty_generate(@job.attributes))}</pre>
|
|
667
663
|
</div>
|
|
668
664
|
</div>
|
|
669
|
-
|
|
670
|
-
function
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
665
|
+
#{script_tag_open}
|
|
666
|
+
(function() {
|
|
667
|
+
document.addEventListener('click', function(e) {
|
|
668
|
+
var el = e.target.closest('[data-action]');
|
|
669
|
+
if (!el) return;
|
|
670
|
+
|
|
671
|
+
if (el.dataset.stopPropagation === 'true') e.stopPropagation();
|
|
672
|
+
|
|
673
|
+
var action = el.dataset.action;
|
|
674
|
+
|
|
675
|
+
if (action === 'copy') {
|
|
676
|
+
var target = document.getElementById(el.dataset.target);
|
|
677
|
+
if (!target) return;
|
|
678
|
+
var text = target.innerText || target.textContent;
|
|
679
|
+
navigator.clipboard.writeText(text).then(function() {
|
|
680
|
+
var original = el.innerHTML;
|
|
681
|
+
el.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
|
|
682
|
+
setTimeout(function() { el.innerHTML = original; }, 2000);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (action === 'show-backtrace') {
|
|
687
|
+
var which = el.dataset.backtrace;
|
|
688
|
+
var appEl = document.getElementById('app-backtrace');
|
|
689
|
+
var fullEl = document.getElementById('full-backtrace');
|
|
690
|
+
if (appEl) appEl.classList.toggle('is-hidden', which !== 'app');
|
|
691
|
+
if (fullEl) fullEl.classList.toggle('is-hidden', which !== 'full');
|
|
692
|
+
document.querySelectorAll('[data-action="show-backtrace"]').forEach(function(btn) {
|
|
693
|
+
btn.classList.toggle('active', btn.dataset.backtrace === which);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (action === 'toggle-section') {
|
|
698
|
+
var section = el.closest('.collapsible-section');
|
|
699
|
+
if (section) section.classList.toggle('is-expanded');
|
|
700
|
+
}
|
|
690
701
|
});
|
|
691
|
-
}
|
|
702
|
+
})();
|
|
692
703
|
</script>
|
|
693
704
|
HTML
|
|
694
705
|
end
|
|
@@ -119,7 +119,7 @@ module SolidQueueMonitor
|
|
|
119
119
|
</form>
|
|
120
120
|
|
|
121
121
|
<form method="post" action="#{discard_failed_job_path(id: failed_execution.id)}" class="inline-form"
|
|
122
|
-
|
|
122
|
+
data-confirm="Are you sure you want to discard this job?">
|
|
123
123
|
<input type="hidden" name="redirect_to" value="#{root_path}">
|
|
124
124
|
<button type="submit" class="action-button discard-button">Discard</button>
|
|
125
125
|
</form>
|
|
@@ -53,7 +53,7 @@ module SolidQueueMonitor
|
|
|
53
53
|
else
|
|
54
54
|
<<-HTML
|
|
55
55
|
<form action="#{pause_queue_path}" method="post" class="inline-form"
|
|
56
|
-
|
|
56
|
+
data-confirm="Are you sure you want to pause this queue?">
|
|
57
57
|
<input type="hidden" name="queue_name" value="#{@queue_name}">
|
|
58
58
|
<input type="hidden" name="redirect_to" value="#{queue_details_path(queue_name: @queue_name)}">
|
|
59
59
|
<button type="submit" class="action-button pause-button">Pause Queue</button>
|
|
@@ -170,7 +170,7 @@ module SolidQueueMonitor
|
|
|
170
170
|
<button type="submit" class="action-button retry-button">Retry</button>
|
|
171
171
|
</form>
|
|
172
172
|
<form method="post" action="#{discard_failed_job_path(id: failed_execution.id)}" class="inline-form"
|
|
173
|
-
|
|
173
|
+
data-confirm="Are you sure you want to discard this job?">
|
|
174
174
|
<input type="hidden" name="redirect_to" value="#{queue_details_path(queue_name: @queue_name)}">
|
|
175
175
|
<button type="submit" class="action-button discard-button">Discard</button>
|
|
176
176
|
</form>
|
|
@@ -76,7 +76,7 @@ module SolidQueueMonitor
|
|
|
76
76
|
else
|
|
77
77
|
<<-HTML
|
|
78
78
|
<form action="#{pause_queue_path}" method="post" class="inline-form"
|
|
79
|
-
|
|
79
|
+
data-confirm="Are you sure you want to pause the #{queue_name} queue? Workers will stop processing jobs from this queue.">
|
|
80
80
|
<input type="hidden" name="queue_name" value="#{queue_name}">
|
|
81
81
|
<button type="submit" class="action-button pause-button" title="Pause queue processing">
|
|
82
82
|
Pause
|
|
@@ -5,12 +5,13 @@ module SolidQueueMonitor
|
|
|
5
5
|
include Rails.application.routes.url_helpers
|
|
6
6
|
include SolidQueueMonitor::Engine.routes.url_helpers
|
|
7
7
|
|
|
8
|
-
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
|
|
8
|
+
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}, nonce: nil)
|
|
9
9
|
@jobs = jobs
|
|
10
10
|
@current_page = current_page
|
|
11
11
|
@total_pages = total_pages
|
|
12
12
|
@filters = filters
|
|
13
13
|
@sort = sort
|
|
14
|
+
@nonce = nonce
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def render
|
|
@@ -19,6 +20,10 @@ module SolidQueueMonitor
|
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
23
|
+
def script_tag_open
|
|
24
|
+
@nonce ? %(<script nonce="#{@nonce}">) : '<script>'
|
|
25
|
+
end
|
|
26
|
+
|
|
22
27
|
def generate_filter_form
|
|
23
28
|
<<-HTML
|
|
24
29
|
<div class="filter-form-container">
|
|
@@ -57,7 +62,7 @@ module SolidQueueMonitor
|
|
|
57
62
|
<form id="scheduled-jobs-form" method="POST">
|
|
58
63
|
#{generate_table}
|
|
59
64
|
</form>
|
|
60
|
-
|
|
65
|
+
#{script_tag_open}
|
|
61
66
|
document.addEventListener('DOMContentLoaded', function() {
|
|
62
67
|
const selectAllCheckbox = document.querySelector('th input[type="checkbox"]');
|
|
63
68
|
const jobCheckboxes = document.getElementsByName('job_ids[]');
|
|
@@ -104,12 +104,17 @@ module SolidQueueMonitor
|
|
|
104
104
|
def prune_all_link
|
|
105
105
|
return '' if @dead_count.zero?
|
|
106
106
|
|
|
107
|
+
suffix = @dead_count > 1 ? 'es' : ''
|
|
108
|
+
message = "Remove all #{@dead_count} dead process#{suffix}? " \
|
|
109
|
+
'This will clean up processes that have stopped sending heartbeats.'
|
|
110
|
+
|
|
107
111
|
<<-HTML
|
|
108
112
|
<a href="#" class="summary-action"
|
|
109
|
-
|
|
113
|
+
data-confirm-submit="prune-all-form"
|
|
114
|
+
data-confirm="#{CGI.escapeHTML(message)}">
|
|
110
115
|
Prune all
|
|
111
116
|
</a>
|
|
112
|
-
<form id="prune-all-form" action="#{prune_workers_path}" method="post"
|
|
117
|
+
<form id="prune-all-form" action="#{prune_workers_path}" method="post" class="is-hidden"></form>
|
|
113
118
|
HTML
|
|
114
119
|
end
|
|
115
120
|
|
|
@@ -185,7 +190,7 @@ module SolidQueueMonitor
|
|
|
185
190
|
|
|
186
191
|
<<-HTML
|
|
187
192
|
<form action="#{remove_worker_path(id: process.id)}" method="post" class="inline-form"
|
|
188
|
-
|
|
193
|
+
data-confirm="Remove this dead process from the registry?">
|
|
189
194
|
<button type="submit" class="action-button discard-button" title="Remove dead process">
|
|
190
195
|
Remove
|
|
191
196
|
</button>
|
|
@@ -85,10 +85,10 @@ module SolidQueueMonitor
|
|
|
85
85
|
def bucket_index_expr(column, start_epoch, interval_seconds)
|
|
86
86
|
if adapter?('sqlite')
|
|
87
87
|
"CAST((CAST(strftime('%s', #{column}) AS INTEGER) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
|
|
88
|
-
elsif adapter?('mysql')
|
|
89
|
-
"
|
|
88
|
+
elsif adapter?('mysql') || adapter?('trilogy')
|
|
89
|
+
"FLOOR((UNIX_TIMESTAMP(#{column}) - #{start_epoch}) / #{interval_seconds})"
|
|
90
90
|
else
|
|
91
|
-
"
|
|
91
|
+
"FLOOR((EXTRACT(EPOCH FROM #{column}) - #{start_epoch}) / #{interval_seconds})::integer"
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
|
|
@@ -66,7 +66,7 @@ module SolidQueueMonitor
|
|
|
66
66
|
|
|
67
67
|
<<-HTML
|
|
68
68
|
<div class="chart-time-select-wrapper">
|
|
69
|
-
<select class="chart-time-select" id="chart-time-select"
|
|
69
|
+
<select class="chart-time-select" id="chart-time-select">
|
|
70
70
|
#{options}
|
|
71
71
|
</select>
|
|
72
72
|
</div>
|
|
@@ -212,15 +212,15 @@ module SolidQueueMonitor
|
|
|
212
212
|
<<-HTML
|
|
213
213
|
<div class="chart-legend">
|
|
214
214
|
<span class="legend-item">
|
|
215
|
-
<span class="legend-color
|
|
215
|
+
<span class="legend-color legend-color-created"></span>
|
|
216
216
|
Created
|
|
217
217
|
</span>
|
|
218
218
|
<span class="legend-item">
|
|
219
|
-
<span class="legend-color
|
|
219
|
+
<span class="legend-color legend-color-completed"></span>
|
|
220
220
|
Completed
|
|
221
221
|
</span>
|
|
222
222
|
<span class="legend-item">
|
|
223
|
-
<span class="legend-color
|
|
223
|
+
<span class="legend-color legend-color-failed"></span>
|
|
224
224
|
Failed
|
|
225
225
|
</span>
|
|
226
226
|
</div>
|
|
@@ -229,7 +229,7 @@ module SolidQueueMonitor
|
|
|
229
229
|
|
|
230
230
|
def render_tooltip
|
|
231
231
|
<<-HTML
|
|
232
|
-
<div id="chart-tooltip" class="chart-tooltip"
|
|
232
|
+
<div id="chart-tooltip" class="chart-tooltip">
|
|
233
233
|
<div class="tooltip-label"></div>
|
|
234
234
|
<div class="tooltip-value"></div>
|
|
235
235
|
</div>
|
|
@@ -5,12 +5,13 @@ module SolidQueueMonitor
|
|
|
5
5
|
include Rails.application.routes.url_helpers
|
|
6
6
|
include SolidQueueMonitor::Engine.routes.url_helpers
|
|
7
7
|
|
|
8
|
-
def initialize(title:, content:, message: nil, message_type: nil, search_query: nil)
|
|
8
|
+
def initialize(title:, content:, message: nil, message_type: nil, search_query: nil, nonce: nil)
|
|
9
9
|
@title = title
|
|
10
10
|
@content = content
|
|
11
11
|
@message = message
|
|
12
12
|
@message_type = message_type
|
|
13
13
|
@search_query = search_query
|
|
14
|
+
@nonce = nonce
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def generate
|
|
@@ -34,7 +35,7 @@ module SolidQueueMonitor
|
|
|
34
35
|
<<-HTML
|
|
35
36
|
<meta charset="UTF-8">
|
|
36
37
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
37
|
-
|
|
38
|
+
#{style_tag_open}
|
|
38
39
|
#{SolidQueueMonitor::StylesheetGenerator.new.generate}
|
|
39
40
|
</style>
|
|
40
41
|
HTML
|
|
@@ -62,27 +63,14 @@ module SolidQueueMonitor
|
|
|
62
63
|
class_name = @message_type == 'success' ? 'message-success' : 'message-error'
|
|
63
64
|
<<-HTML
|
|
64
65
|
<div id="flash-message" class="message #{class_name}">#{@message}</div>
|
|
65
|
-
|
|
66
|
-
// Automatically hide the flash message after 5 seconds
|
|
66
|
+
#{script_tag_open}
|
|
67
67
|
document.addEventListener('DOMContentLoaded', function() {
|
|
68
|
-
var
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!flashMessage.style.opacity) {
|
|
75
|
-
flashMessage.style.opacity = 1;
|
|
76
|
-
}
|
|
77
|
-
if (flashMessage.style.opacity > 0) {
|
|
78
|
-
flashMessage.style.opacity -= 0.1;
|
|
79
|
-
} else {
|
|
80
|
-
clearInterval(fadeEffect);
|
|
81
|
-
flashMessage.style.display = 'none';
|
|
82
|
-
}
|
|
83
|
-
}, 50);
|
|
84
|
-
}, 5000); // 5 seconds
|
|
85
|
-
}
|
|
68
|
+
var el = document.getElementById('flash-message');
|
|
69
|
+
if (!el) return;
|
|
70
|
+
setTimeout(function() {
|
|
71
|
+
el.classList.add('is-fading');
|
|
72
|
+
setTimeout(function() { el.classList.add('is-hidden'); }, 500);
|
|
73
|
+
}, 5000);
|
|
86
74
|
});
|
|
87
75
|
</script>
|
|
88
76
|
HTML
|
|
@@ -149,6 +137,14 @@ module SolidQueueMonitor
|
|
|
149
137
|
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
150
138
|
end
|
|
151
139
|
|
|
140
|
+
def style_tag_open
|
|
141
|
+
@nonce ? %(<style nonce="#{@nonce}">) : '<style>'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def script_tag_open
|
|
145
|
+
@nonce ? %(<script nonce="#{@nonce}">) : '<script>'
|
|
146
|
+
end
|
|
147
|
+
|
|
152
148
|
def generate_auto_refresh_controls
|
|
153
149
|
return '' unless SolidQueueMonitor.auto_refresh_enabled
|
|
154
150
|
|
|
@@ -194,7 +190,7 @@ module SolidQueueMonitor
|
|
|
194
190
|
def generate_auto_refresh_script
|
|
195
191
|
return '' unless SolidQueueMonitor.auto_refresh_enabled
|
|
196
192
|
|
|
197
|
-
"
|
|
193
|
+
"#{script_tag_open}#{auto_refresh_javascript}</script>"
|
|
198
194
|
end
|
|
199
195
|
|
|
200
196
|
def auto_refresh_javascript
|
|
@@ -229,7 +225,7 @@ module SolidQueueMonitor
|
|
|
229
225
|
if (indicator) indicator.classList.toggle('active', isEnabled);
|
|
230
226
|
if (countdownEl) {
|
|
231
227
|
countdownEl.textContent = countdown + 's';
|
|
232
|
-
countdownEl.
|
|
228
|
+
countdownEl.classList.toggle('countdown-paused', !isEnabled);
|
|
233
229
|
}
|
|
234
230
|
}
|
|
235
231
|
function tick() {
|
|
@@ -270,9 +266,10 @@ module SolidQueueMonitor
|
|
|
270
266
|
|
|
271
267
|
def generate_chart_script
|
|
272
268
|
<<-HTML
|
|
273
|
-
|
|
269
|
+
#{script_tag_open}
|
|
274
270
|
#{theme_toggle_javascript}
|
|
275
271
|
#{chart_tooltip_javascript}
|
|
272
|
+
#{global_behaviors_javascript}
|
|
276
273
|
</script>
|
|
277
274
|
HTML
|
|
278
275
|
end
|
|
@@ -363,7 +360,7 @@ module SolidQueueMonitor
|
|
|
363
360
|
|
|
364
361
|
tooltip.querySelector('.tooltip-label').textContent = label;
|
|
365
362
|
tooltip.querySelector('.tooltip-value').textContent = seriesNames[series] + ': ' + value;
|
|
366
|
-
tooltip.
|
|
363
|
+
tooltip.classList.add('tooltip-visible');
|
|
367
364
|
positionTooltip(e);
|
|
368
365
|
});
|
|
369
366
|
|
|
@@ -372,7 +369,7 @@ module SolidQueueMonitor
|
|
|
372
369
|
});
|
|
373
370
|
|
|
374
371
|
point.addEventListener('mouseleave', function() {
|
|
375
|
-
tooltip.
|
|
372
|
+
tooltip.classList.remove('tooltip-visible');
|
|
376
373
|
});
|
|
377
374
|
});
|
|
378
375
|
|
|
@@ -387,6 +384,7 @@ module SolidQueueMonitor
|
|
|
387
384
|
y = e.clientY + 10;
|
|
388
385
|
}
|
|
389
386
|
|
|
387
|
+
// Dynamic cursor-tracked position, not CSP-restricted.
|
|
390
388
|
tooltip.style.left = x + 'px';
|
|
391
389
|
tooltip.style.top = y + 'px';
|
|
392
390
|
}
|
|
@@ -394,6 +392,34 @@ module SolidQueueMonitor
|
|
|
394
392
|
JS
|
|
395
393
|
end
|
|
396
394
|
|
|
395
|
+
def global_behaviors_javascript
|
|
396
|
+
<<-JS
|
|
397
|
+
document.addEventListener('submit', function(e) {
|
|
398
|
+
var form = e.target;
|
|
399
|
+
var msg = form.dataset && form.dataset.confirm;
|
|
400
|
+
if (msg && !window.confirm(msg)) { e.preventDefault(); }
|
|
401
|
+
}, true);
|
|
402
|
+
|
|
403
|
+
document.addEventListener('click', function(e) {
|
|
404
|
+
var el = e.target.closest('[data-confirm-submit]');
|
|
405
|
+
if (!el) return;
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
var msg = el.dataset.confirm || 'Are you sure?';
|
|
408
|
+
if (!window.confirm(msg)) return;
|
|
409
|
+
var formId = el.dataset.confirmSubmit;
|
|
410
|
+
var form = document.getElementById(formId);
|
|
411
|
+
if (form) form.submit();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
var timeRangeSelect = document.getElementById('chart-time-select');
|
|
415
|
+
if (timeRangeSelect) {
|
|
416
|
+
timeRangeSelect.addEventListener('change', function() {
|
|
417
|
+
window.location.href = '?time_range=' + this.value;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
JS
|
|
421
|
+
end
|
|
422
|
+
|
|
397
423
|
def default_url_options
|
|
398
424
|
{ only_path: true }
|
|
399
425
|
end
|
|
@@ -1088,6 +1088,18 @@ module SolidQueueMonitor
|
|
|
1088
1088
|
border-radius: 2px;
|
|
1089
1089
|
}
|
|
1090
1090
|
|
|
1091
|
+
.solid_queue_monitor .legend-color-created {
|
|
1092
|
+
background-color: #3b82f6;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
.solid_queue_monitor .legend-color-completed {
|
|
1096
|
+
background-color: #10b981;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
.solid_queue_monitor .legend-color-failed {
|
|
1100
|
+
background-color: #ef4444;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1091
1103
|
.solid_queue_monitor .chart-tooltip {
|
|
1092
1104
|
position: fixed;
|
|
1093
1105
|
background: #1f2937;
|
|
@@ -2017,6 +2029,51 @@ module SolidQueueMonitor
|
|
|
2017
2029
|
grid-template-columns: 1fr;
|
|
2018
2030
|
}
|
|
2019
2031
|
}
|
|
2032
|
+
|
|
2033
|
+
/* ===== CSP Phase 1 utility classes (replace runtime style mutations) ===== */
|
|
2034
|
+
|
|
2035
|
+
.is-hidden {
|
|
2036
|
+
display: none !important;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
.countdown-paused {
|
|
2040
|
+
opacity: 0.4;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
.is-expanded .collapse-icon {
|
|
2044
|
+
transform: rotate(90deg);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
.collapse-icon {
|
|
2048
|
+
transition: transform 150ms ease;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
.collapsible-content {
|
|
2052
|
+
display: none;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
.is-expanded .collapsible-content {
|
|
2056
|
+
display: block;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
.chart-tooltip {
|
|
2060
|
+
display: none;
|
|
2061
|
+
position: fixed;
|
|
2062
|
+
pointer-events: none;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
.chart-tooltip.tooltip-visible {
|
|
2066
|
+
display: block;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
#flash-message {
|
|
2070
|
+
opacity: 1;
|
|
2071
|
+
transition: opacity 500ms ease;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
#flash-message.is-fading {
|
|
2075
|
+
opacity: 0;
|
|
2076
|
+
}
|
|
2020
2077
|
CSS
|
|
2021
2078
|
end
|
|
2022
2079
|
end
|