solid_observer 0.1.0 → 0.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/CHANGELOG.md +73 -0
- data/README.md +198 -36
- data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
- data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
- data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
- data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
- data/app/controllers/solid_observer/application_controller.rb +69 -0
- data/app/controllers/solid_observer/dashboard_controller.rb +79 -0
- data/app/controllers/solid_observer/events_controller.rb +50 -0
- data/app/controllers/solid_observer/jobs_controller.rb +85 -0
- data/app/controllers/solid_observer/storages_controller.rb +12 -0
- data/app/helpers/solid_observer/application_helper.rb +95 -0
- data/app/helpers/solid_observer/dashboard_helper.rb +39 -0
- data/app/models/solid_observer/queue_event.rb +134 -0
- data/app/models/solid_observer/queue_metric.rb +1 -1
- data/app/presenters/solid_observer/execution_presenter.rb +50 -0
- data/app/views/layouts/solid_observer/application.html.erb +470 -0
- data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
- data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
- data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
- data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
- data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
- data/app/views/solid_observer/dashboard/index.html.erb +113 -0
- data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
- data/app/views/solid_observer/events/index.html.erb +53 -0
- data/app/views/solid_observer/events/show.html.erb +47 -0
- data/app/views/solid_observer/jobs/index.html.erb +61 -0
- data/app/views/solid_observer/jobs/show.html.erb +71 -0
- data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
- data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
- data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
- data/app/views/solid_observer/storages/show.html.erb +39 -0
- data/bin/quality_gate +95 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
- data/lib/generators/solid_observer/install_generator.rb +12 -25
- data/lib/generators/solid_observer/templates/initializer.rb.tt +5 -6
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/chart_buffer.rb +83 -0
- data/lib/solid_observer/cli/base.rb +2 -2
- data/lib/solid_observer/cli/jobs.rb +2 -2
- data/lib/solid_observer/cli/status.rb +20 -2
- data/lib/solid_observer/cli/storage.rb +41 -32
- data/lib/solid_observer/configuration.rb +67 -34
- data/lib/solid_observer/correlation_id_resolver.rb +8 -6
- data/lib/solid_observer/engine.rb +75 -15
- data/lib/solid_observer/params/events_filter.rb +37 -0
- data/lib/solid_observer/params/jobs_filter.rb +35 -0
- data/lib/solid_observer/queries/events_query.rb +27 -0
- data/lib/solid_observer/queries/execution_finder.rb +42 -0
- data/lib/solid_observer/queries/job_executions_query.rb +73 -0
- data/lib/solid_observer/queue_event_buffer.rb +163 -22
- data/lib/solid_observer/queue_stats.rb +165 -19
- data/lib/solid_observer/services/cleanup_storage.rb +60 -42
- data/lib/solid_observer/services/database_size.rb +86 -0
- data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
- data/lib/solid_observer/services/install_migrations.rb +49 -0
- data/lib/solid_observer/services/record_event.rb +53 -14
- data/lib/solid_observer/services/ui_auth_check.rb +65 -0
- data/lib/solid_observer/subscriber.rb +15 -8
- data/lib/solid_observer/version.rb +1 -1
- data/lib/solid_observer.rb +7 -0
- data/lib/tasks/solid_observer.rake +10 -2
- metadata +55 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var MAX_POINTS = 60;
|
|
5
|
+
var SVG_W = 120,
|
|
6
|
+
SVG_H = 32;
|
|
7
|
+
var INTERVAL_SEC = 5;
|
|
8
|
+
|
|
9
|
+
// Shared state — IIFE-level so all functions can access them
|
|
10
|
+
var checkbox, rangeSelect, refreshBtn, helpBtn, helpPanel, helpWrapper, freshnessEl;
|
|
11
|
+
var hoverActive = false;
|
|
12
|
+
var sparks = {};
|
|
13
|
+
var url = "/solid_observer/poll_data";
|
|
14
|
+
var inFlight = false;
|
|
15
|
+
var timerId = null;
|
|
16
|
+
var lastFullSnapshot = null;
|
|
17
|
+
var lastFullChart = null;
|
|
18
|
+
var lastRange = null;
|
|
19
|
+
|
|
20
|
+
function init() {
|
|
21
|
+
var wrapper = document.querySelector("[data-so-live]");
|
|
22
|
+
if (!wrapper) return;
|
|
23
|
+
|
|
24
|
+
checkbox = wrapper.querySelector('[data-so-live-toggle]');
|
|
25
|
+
if (!checkbox) return;
|
|
26
|
+
|
|
27
|
+
rangeSelect = wrapper.querySelector("[data-so-range-select]");
|
|
28
|
+
refreshBtn = wrapper.querySelector("[data-so-refresh]");
|
|
29
|
+
helpBtn = wrapper.querySelector("[data-so-help-btn]");
|
|
30
|
+
helpPanel = wrapper.querySelector("[data-so-help-panel]");
|
|
31
|
+
helpWrapper = wrapper.querySelector("[data-so-help-wrapper]");
|
|
32
|
+
freshnessEl = wrapper.querySelector("[data-so-freshness]");
|
|
33
|
+
|
|
34
|
+
sparks = collectSparks();
|
|
35
|
+
lastRange = readRangeFromUrl() || "15m";
|
|
36
|
+
|
|
37
|
+
// --- Range change (full fetch) ---
|
|
38
|
+
if (rangeSelect) {
|
|
39
|
+
rangeSelect.addEventListener("change", function () {
|
|
40
|
+
lastRange = rangeSelect.value;
|
|
41
|
+
updateUrlRange(lastRange);
|
|
42
|
+
updateUrlLive(checkbox.checked);
|
|
43
|
+
fullFetch();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Refresh button (full fetch) ---
|
|
48
|
+
if (refreshBtn) {
|
|
49
|
+
refreshBtn.addEventListener("click", function () {
|
|
50
|
+
fullFetch();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Help disclosure ---
|
|
55
|
+
if (helpBtn && helpPanel) {
|
|
56
|
+
helpBtn.addEventListener("click", function () {
|
|
57
|
+
var expanded = helpBtn.getAttribute("aria-expanded") === "true";
|
|
58
|
+
// Don't close on click if hover is active — mouseleave will handle closing
|
|
59
|
+
if (expanded && hoverActive) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
helpBtn.setAttribute("aria-expanded", String(!expanded));
|
|
63
|
+
helpPanel.hidden = expanded;
|
|
64
|
+
});
|
|
65
|
+
helpBtn.addEventListener("keydown", function (e) {
|
|
66
|
+
if (e.key === "Escape") {
|
|
67
|
+
helpBtn.setAttribute("aria-expanded", "false");
|
|
68
|
+
helpPanel.hidden = true;
|
|
69
|
+
helpBtn.focus();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
helpBtn.addEventListener("focusout", function (e) {
|
|
73
|
+
var related = e.relatedTarget;
|
|
74
|
+
if (!related || !helpPanel.contains(related)) {
|
|
75
|
+
helpBtn.setAttribute("aria-expanded", "false");
|
|
76
|
+
helpPanel.hidden = true;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
document.addEventListener("click", function (e) {
|
|
80
|
+
if (helpPanel && !helpPanel.contains(e.target) && e.target !== helpBtn) {
|
|
81
|
+
helpBtn.setAttribute("aria-expanded", "false");
|
|
82
|
+
helpPanel.hidden = true;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
// Focusout/blur close behavior
|
|
86
|
+
helpPanel.addEventListener("focusout", function (e) {
|
|
87
|
+
if (!helpPanel.contains(e.relatedTarget) && e.relatedTarget !== helpBtn) {
|
|
88
|
+
helpBtn.setAttribute("aria-expanded", "false");
|
|
89
|
+
helpPanel.hidden = true;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// Hover show/hide for mouse users
|
|
93
|
+
if (helpWrapper) {
|
|
94
|
+
helpWrapper.addEventListener("mouseenter", function () {
|
|
95
|
+
hoverActive = true;
|
|
96
|
+
helpBtn.setAttribute("aria-expanded", "true");
|
|
97
|
+
helpPanel.hidden = false;
|
|
98
|
+
});
|
|
99
|
+
helpWrapper.addEventListener("mouseleave", function () {
|
|
100
|
+
hoverActive = false;
|
|
101
|
+
helpBtn.setAttribute("aria-expanded", "false");
|
|
102
|
+
helpPanel.hidden = true;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Live toggle ---
|
|
108
|
+
checkbox.addEventListener("change", function () {
|
|
109
|
+
updateUrlLive(checkbox.checked);
|
|
110
|
+
var label = checkbox.closest("label");
|
|
111
|
+
label.classList.toggle("so-toggle--on", checkbox.checked);
|
|
112
|
+
label.querySelector(".so-toggle__cadence").textContent = checkbox.checked
|
|
113
|
+
? "5s"
|
|
114
|
+
: "off";
|
|
115
|
+
if (checkbox.checked) {
|
|
116
|
+
start();
|
|
117
|
+
} else {
|
|
118
|
+
stop();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
document.addEventListener("visibilitychange", function () {
|
|
123
|
+
if (document.hidden) {
|
|
124
|
+
stop();
|
|
125
|
+
} else if (checkbox.checked) {
|
|
126
|
+
tick();
|
|
127
|
+
start();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (checkbox.checked) start();
|
|
132
|
+
checkbox
|
|
133
|
+
.closest("label")
|
|
134
|
+
.classList.toggle("so-toggle--on", checkbox.checked);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fullFetch() {
|
|
138
|
+
if (inFlight) return;
|
|
139
|
+
inFlight = true;
|
|
140
|
+
|
|
141
|
+
if (refreshBtn) {
|
|
142
|
+
refreshBtn.textContent = "Refreshing\u2026";
|
|
143
|
+
refreshBtn.setAttribute("aria-busy", "true");
|
|
144
|
+
refreshBtn.disabled = true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add loading class to range-bound zones
|
|
148
|
+
addLoadingClass(true);
|
|
149
|
+
|
|
150
|
+
var rangeParam = lastRange || readRangeFromUrl() || "15m";
|
|
151
|
+
fetch(
|
|
152
|
+
url + "?range=" + encodeURIComponent(rangeParam),
|
|
153
|
+
{ headers: { Accept: "application/json" }, credentials: "same-origin" }
|
|
154
|
+
)
|
|
155
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
156
|
+
.then(function (data) {
|
|
157
|
+
if (data) {
|
|
158
|
+
lastFullSnapshot = data.snapshot || {};
|
|
159
|
+
lastFullChart = data.chart || {};
|
|
160
|
+
applyFullUpdate(data);
|
|
161
|
+
updateFreshness("Updated just now");
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
.catch(function () { /* drop silently */ })
|
|
165
|
+
.finally(function () {
|
|
166
|
+
inFlight = false;
|
|
167
|
+
addLoadingClass(false);
|
|
168
|
+
if (refreshBtn) {
|
|
169
|
+
refreshBtn.textContent = "Refresh data";
|
|
170
|
+
refreshBtn.setAttribute("aria-busy", "false");
|
|
171
|
+
refreshBtn.disabled = false;
|
|
172
|
+
refreshBtn.focus();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function tick() {
|
|
178
|
+
if (inFlight) return;
|
|
179
|
+
inFlight = true;
|
|
180
|
+
var rangeParam = lastRange || readRangeFromUrl() || "15m";
|
|
181
|
+
fetch(
|
|
182
|
+
url + "?range=" + encodeURIComponent(rangeParam) + "&tick=true",
|
|
183
|
+
{ headers: { Accept: "application/json" }, credentials: "same-origin" }
|
|
184
|
+
)
|
|
185
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
186
|
+
.then(function (data) {
|
|
187
|
+
if (data) applyTickUpdate(data);
|
|
188
|
+
})
|
|
189
|
+
.catch(function () { /* drop tick silently */ })
|
|
190
|
+
.finally(function () { inFlight = false; });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function applyFullUpdate(data) {
|
|
194
|
+
var snapshot = data.snapshot || {};
|
|
195
|
+
// Patch live-state values (Zone B)
|
|
196
|
+
patchZoneValues("live-state", snapshot);
|
|
197
|
+
// Patch throughput values (Zone C) — value nodes only, preserve suffix/range-copy
|
|
198
|
+
patchZoneValues("throughput", snapshot);
|
|
199
|
+
// Patch chart indicator values (Zone D) — range totals, not latest bucket
|
|
200
|
+
patchZoneValues("chart", snapshot);
|
|
201
|
+
// Patch queue table (Zone E)
|
|
202
|
+
patchQueueTable(snapshot);
|
|
203
|
+
// Patch stability (Zone F)
|
|
204
|
+
patchStability(snapshot);
|
|
205
|
+
// Update chart sparklines (Zone D)
|
|
206
|
+
var chart = data.chart || {};
|
|
207
|
+
Object.keys(sparks).forEach(function (key) {
|
|
208
|
+
var series = chart[key];
|
|
209
|
+
if (Array.isArray(series)) sparks[key].render(series);
|
|
210
|
+
});
|
|
211
|
+
// Update range-copy nodes from server-provided label
|
|
212
|
+
if (data.range_label) {
|
|
213
|
+
updateRangeCopyFromLabel(data.range_label);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function applyTickUpdate(data) {
|
|
218
|
+
var snapshot = data.snapshot || {};
|
|
219
|
+
// Tick only patches live-state values (Zone B)
|
|
220
|
+
patchZoneValues("live-state", snapshot);
|
|
221
|
+
// chart is nil on tick — preserve last full-fetch chart/range state
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function patchZoneValues(zone, snapshot) {
|
|
225
|
+
var zoneEl = document.querySelector('[data-so-zone="' + zone + '"]');
|
|
226
|
+
if (!zoneEl) return;
|
|
227
|
+
var valueEls = zoneEl.querySelectorAll("[data-so-card-value]");
|
|
228
|
+
valueEls.forEach(function (el) {
|
|
229
|
+
var key = el.getAttribute("data-so-card-value");
|
|
230
|
+
if (snapshot.hasOwnProperty(key)) {
|
|
231
|
+
var val = snapshot[key];
|
|
232
|
+
// Duration values arrive in seconds; display as milliseconds
|
|
233
|
+
if (key === "avg_duration_in_range" && val !== null && val !== undefined) {
|
|
234
|
+
var ms = Math.round(val * 1000);
|
|
235
|
+
el.textContent = formatValue(ms);
|
|
236
|
+
var suffixEl = el.parentElement.querySelector("[data-so-card-suffix]");
|
|
237
|
+
if (suffixEl) suffixEl.textContent = "ms";
|
|
238
|
+
} else {
|
|
239
|
+
el.textContent = formatValue(val);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function patchQueueTable(snapshot) {
|
|
246
|
+
var tableEl = document.querySelector('[data-so-zone="queue-table"]');
|
|
247
|
+
if (!tableEl) return;
|
|
248
|
+
var queues = snapshot.queues || {};
|
|
249
|
+
var performedByQueue = snapshot.performed_by_queue || {};
|
|
250
|
+
var failedByQueue = snapshot.failed_by_queue || {};
|
|
251
|
+
|
|
252
|
+
// Update live depth values
|
|
253
|
+
Object.keys(queues).forEach(function (qName) {
|
|
254
|
+
var el = tableEl.querySelector('[data-so-table-value="queue-depth-' + qName + '"]');
|
|
255
|
+
if (el) el.textContent = formatValue(queues[qName]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Update performed/failed in range
|
|
259
|
+
Object.keys(performedByQueue).forEach(function (qName) {
|
|
260
|
+
var el = tableEl.querySelector('[data-so-table-value="queue-performed-' + qName + '"]');
|
|
261
|
+
if (el) el.textContent = formatValue(performedByQueue[qName]);
|
|
262
|
+
});
|
|
263
|
+
Object.keys(failedByQueue).forEach(function (qName) {
|
|
264
|
+
var el = tableEl.querySelector('[data-so-table-value="queue-failed-' + qName + '"]');
|
|
265
|
+
if (el) el.textContent = formatValue(failedByQueue[qName]);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function patchStability(snapshot) {
|
|
270
|
+
// Stability is rendered server-side; tick does not update it.
|
|
271
|
+
// Full fetch re-renders via page or could patch, but we keep it simple.
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function updateRangeCopyFromLabel(label) {
|
|
275
|
+
var els = document.querySelectorAll("[data-so-range-copy]");
|
|
276
|
+
els.forEach(function (el) { el.textContent = label; });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function updateFreshness(text) {
|
|
280
|
+
if (freshnessEl) freshnessEl.textContent = text;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function addLoadingClass(on) {
|
|
284
|
+
var zones = document.querySelectorAll(
|
|
285
|
+
'[data-so-zone="throughput"], [data-so-zone="chart"], [data-so-zone="queue-table"]'
|
|
286
|
+
);
|
|
287
|
+
zones.forEach(function (el) {
|
|
288
|
+
var section = el.closest(".so-dashboard-section") || el;
|
|
289
|
+
if (on) {
|
|
290
|
+
section.classList.add("is-loading");
|
|
291
|
+
} else {
|
|
292
|
+
section.classList.remove("is-loading");
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function start() {
|
|
298
|
+
stop();
|
|
299
|
+
timerId = window.setInterval(tick, INTERVAL_SEC * 1000);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function stop() {
|
|
303
|
+
if (timerId !== null) {
|
|
304
|
+
window.clearInterval(timerId);
|
|
305
|
+
timerId = null;
|
|
306
|
+
}
|
|
307
|
+
inFlight = false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function updateUrlRange(range) {
|
|
311
|
+
var urlObj = new URL(window.location.href);
|
|
312
|
+
urlObj.searchParams.set("range", range);
|
|
313
|
+
window.history.replaceState({}, "", urlObj.toString());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function updateUrlLive(isLive) {
|
|
317
|
+
var urlObj = new URL(window.location.href);
|
|
318
|
+
if (isLive) {
|
|
319
|
+
urlObj.searchParams.set("live", "on");
|
|
320
|
+
} else {
|
|
321
|
+
urlObj.searchParams.delete("live");
|
|
322
|
+
}
|
|
323
|
+
window.history.replaceState({}, "", urlObj.toString());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function collectSparks() {
|
|
327
|
+
var sparks = {};
|
|
328
|
+
var figures = document.querySelectorAll("[data-so-spark]");
|
|
329
|
+
for (var i = 0; i < figures.length; i++) {
|
|
330
|
+
var key = figures[i].getAttribute("data-so-spark");
|
|
331
|
+
sparks[key] = new Sparkline(figures[i]);
|
|
332
|
+
}
|
|
333
|
+
return sparks;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function Sparkline(figureEl) {
|
|
337
|
+
this.line = figureEl.querySelector(".so-spark__line");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
Sparkline.prototype.render = function (series) {
|
|
341
|
+
if (!this.line || !series.length) return;
|
|
342
|
+
var tMin = series[0].t,
|
|
343
|
+
tMax = series[series.length - 1].t;
|
|
344
|
+
var vMax = 1;
|
|
345
|
+
for (var i = 0; i < series.length; i++) {
|
|
346
|
+
if (series[i].v > vMax) vMax = series[i].v;
|
|
347
|
+
}
|
|
348
|
+
var points = [];
|
|
349
|
+
for (var i = 0; i < series.length; i++) {
|
|
350
|
+
var x =
|
|
351
|
+
tMin === tMax
|
|
352
|
+
? SVG_W / 2
|
|
353
|
+
: ((series[i].t - tMin) / (tMax - tMin)) * (SVG_W - 2) + 1;
|
|
354
|
+
var y = SVG_H - 1 - (series[i].v / vMax) * (SVG_H - 2);
|
|
355
|
+
points.push(x.toFixed(1) + "," + y.toFixed(1));
|
|
356
|
+
}
|
|
357
|
+
this.line.setAttribute("points", points.join(" "));
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
function formatValue(v) {
|
|
361
|
+
if (v === null || v === undefined) return "\u2014";
|
|
362
|
+
if (Number.isInteger(v))
|
|
363
|
+
return v.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
364
|
+
return parseFloat(v).toFixed(1);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readRangeFromUrl() {
|
|
368
|
+
return new URL(window.location.href).searchParams.get("range");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (document.readyState === "loading") {
|
|
372
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
373
|
+
} else {
|
|
374
|
+
init();
|
|
375
|
+
}
|
|
376
|
+
})();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module Paginatable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def paginate_scope(scope, per_page:)
|
|
10
|
+
@total_count = scope.count
|
|
11
|
+
@total_pages = (@total_count.to_f / per_page).ceil
|
|
12
|
+
@page = 1 if @page < 1
|
|
13
|
+
@page = 1 if @page > @total_pages && @total_pages > 0
|
|
14
|
+
(@page - 1) * per_page
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module RequirePersistenceMode
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_action :require_persistence_mode
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def require_persistence_mode
|
|
14
|
+
return unless SolidObserver.config.realtime_mode?
|
|
15
|
+
|
|
16
|
+
redirect_to root_path, alert: "This page is not available in real-time mode."
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module RequireSolidQueue
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_action :require_solid_queue
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def require_solid_queue
|
|
14
|
+
return if SolidObserver::QueueStats.solid_queue_available?
|
|
15
|
+
|
|
16
|
+
redirect_to root_path, alert: "SolidQueue is not available."
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
def self.runtime_db_errors
|
|
6
|
+
[
|
|
7
|
+
*([PG::ConnectionBad] if defined?(PG::ConnectionBad)),
|
|
8
|
+
*([Mysql2::Error::ConnectionError] if defined?(Mysql2::Error::ConnectionError)),
|
|
9
|
+
*([SQLite3::CantOpenException] if defined?(SQLite3::CantOpenException))
|
|
10
|
+
]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
api_controller = defined?(ActionController::API) && begin
|
|
14
|
+
SolidObserver.config.ui_base_controller.constantize.ancestors.include?(ActionController::API)
|
|
15
|
+
rescue NameError
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
if api_controller
|
|
19
|
+
include ActionView::Layouts
|
|
20
|
+
include ActionView::Rendering
|
|
21
|
+
include ActionController::RequestForgeryProtection
|
|
22
|
+
end
|
|
23
|
+
protect_from_forgery with: :exception
|
|
24
|
+
before_action :verify_ui_enabled
|
|
25
|
+
before_action :authenticate
|
|
26
|
+
helper_method :persistence_mode?, :realtime_mode?, :solid_queue_available?
|
|
27
|
+
layout "solid_observer/application"
|
|
28
|
+
rescue_from ActiveRecord::NoDatabaseError,
|
|
29
|
+
ActiveRecord::ConnectionNotEstablished,
|
|
30
|
+
*runtime_db_errors,
|
|
31
|
+
with: :render_storage_unavailable
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def verify_ui_enabled
|
|
36
|
+
render plain: "Not Found", status: :not_found unless SolidObserver.config.ui_enabled
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def authenticate
|
|
40
|
+
cfg = SolidObserver.config
|
|
41
|
+
return unless cfg.ui_username.present? && cfg.ui_password.present?
|
|
42
|
+
authenticate_or_request_with_http_basic("SolidObserver") { |username, password| credentials_valid?(username, password) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def solid_queue_available?
|
|
46
|
+
QueueStats.solid_queue_available?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def persistence_mode?
|
|
50
|
+
SolidObserver.config.persistence_mode?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def realtime_mode?
|
|
54
|
+
SolidObserver.config.realtime_mode?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def credentials_valid?(username, password)
|
|
58
|
+
cfg = SolidObserver.config
|
|
59
|
+
ActiveSupport::SecurityUtils.secure_compare(username.to_s, cfg.ui_username.to_s) &&
|
|
60
|
+
ActiveSupport::SecurityUtils.secure_compare(password.to_s, cfg.ui_password.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_storage_unavailable(exception)
|
|
64
|
+
@error_class = exception.class.name
|
|
65
|
+
@error_message = exception.message
|
|
66
|
+
render "solid_observer/errors/storage_unavailable", status: :service_unavailable
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
skip_forgery_protection only: :live_poll
|
|
6
|
+
skip_after_action :verify_same_origin_request, only: :live_poll
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
assign_range_and_stats
|
|
10
|
+
load_persistence_data if persistence_mode?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def live_poll
|
|
14
|
+
send_file(
|
|
15
|
+
SolidObserver::Engine.root.join("app/assets/javascripts/solid_observer/live_poll.js"),
|
|
16
|
+
type: "application/javascript; charset=utf-8",
|
|
17
|
+
disposition: "inline"
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def poll_data
|
|
22
|
+
range = QueueStats.parse_range(request_range_param, fallback: QueueStats::POLL_DEFAULT_RANGE)
|
|
23
|
+
window = QueueStats.range_duration(range, fallback: QueueStats::POLL_DEFAULT_RANGE)
|
|
24
|
+
append_chart_buffer
|
|
25
|
+
render json: tick_request? ? tick_payload : full_payload(range: range, window: window)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def assign_range_and_stats
|
|
31
|
+
range = QueueStats.parse_range(request_range_param)
|
|
32
|
+
@range = range
|
|
33
|
+
@live = request_live_param == "on"
|
|
34
|
+
@stats = QueueStats.snapshot(range: range)
|
|
35
|
+
@chart = QueueStats.chart_data(window: QueueStats.range_duration(@range))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def load_persistence_data
|
|
39
|
+
@recent_events = QueueEvent.recent(10)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def request_range_param
|
|
43
|
+
request&.query_parameters&.[]("range") || request&.query_parameters&.[](:range)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def request_live_param
|
|
47
|
+
request&.query_parameters&.[]("live") || request&.query_parameters&.[](:live)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def request_tick_param
|
|
51
|
+
request&.query_parameters&.[]("tick") || request&.query_parameters&.[](:tick)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tick_request?
|
|
55
|
+
request_tick_param == "true"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def tick_payload
|
|
59
|
+
{
|
|
60
|
+
mode: persistence_mode? ? "persistence" : "realtime",
|
|
61
|
+
snapshot: QueueStats.snapshot_for_tick,
|
|
62
|
+
chart: nil
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def full_payload(range:, window:)
|
|
67
|
+
{
|
|
68
|
+
mode: persistence_mode? ? "persistence" : "realtime",
|
|
69
|
+
snapshot: QueueStats.snapshot_for_poll(range: range),
|
|
70
|
+
chart: QueueStats.chart_data(window: window),
|
|
71
|
+
range_label: helpers.range_label(range)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def append_chart_buffer
|
|
76
|
+
ChartBuffer.append(SolidQueue::ReadyExecution.count) if QueueStats.solid_queue_available?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class EventsController < ApplicationController
|
|
5
|
+
include Paginatable
|
|
6
|
+
include RequirePersistenceMode
|
|
7
|
+
|
|
8
|
+
PER_PAGE = 50
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
filter = Params::EventsFilter.from_params(params)
|
|
12
|
+
@event_type = filter.event_type
|
|
13
|
+
@job_class = filter.job_class
|
|
14
|
+
@queue_name = filter.queue_name
|
|
15
|
+
@from = filter.from
|
|
16
|
+
@to = filter.to
|
|
17
|
+
@page = filter.page
|
|
18
|
+
scope = Queries::EventsQuery.new(filter).call
|
|
19
|
+
offset = paginate_scope(scope, per_page: PER_PAGE)
|
|
20
|
+
@events = scope.limit(PER_PAGE).offset(offset)
|
|
21
|
+
load_available_options
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
@event = QueueEvent.find_by(id: params[:id])
|
|
26
|
+
return redirect_to(events_path, alert: "Event not found") unless @event
|
|
27
|
+
|
|
28
|
+
@metadata = parse_metadata(@event.metadata)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def load_available_options
|
|
34
|
+
@available_event_types = QueueEvent::EVENT_TYPES
|
|
35
|
+
@available_job_classes = cached_filter_options("solid_observer/events/distinct_job_classes") { QueueEvent.distinct_job_classes }
|
|
36
|
+
@available_queues = cached_filter_options("solid_observer/events/distinct_queue_names") { QueueEvent.distinct_queue_names }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def cached_filter_options(key, &block)
|
|
40
|
+
Rails.cache.fetch(key, expires_in: SolidObserver.config.filter_cache_ttl, &block)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_metadata(metadata)
|
|
44
|
+
return nil if metadata.blank?
|
|
45
|
+
JSON.parse(metadata)
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
{raw: metadata}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|