solid_observer 0.1.1 → 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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/README.md +157 -28
  4. data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
  5. data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
  6. data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
  7. data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
  8. data/app/controllers/solid_observer/application_controller.rb +69 -0
  9. data/app/controllers/solid_observer/dashboard_controller.rb +79 -0
  10. data/app/controllers/solid_observer/events_controller.rb +50 -0
  11. data/app/controllers/solid_observer/jobs_controller.rb +85 -0
  12. data/app/controllers/solid_observer/storages_controller.rb +12 -0
  13. data/app/helpers/solid_observer/application_helper.rb +95 -0
  14. data/app/helpers/solid_observer/dashboard_helper.rb +39 -0
  15. data/app/models/solid_observer/queue_event.rb +134 -0
  16. data/app/models/solid_observer/queue_metric.rb +1 -1
  17. data/app/presenters/solid_observer/execution_presenter.rb +50 -0
  18. data/app/views/layouts/solid_observer/application.html.erb +470 -0
  19. data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
  20. data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
  21. data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
  22. data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
  23. data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
  24. data/app/views/solid_observer/dashboard/index.html.erb +113 -0
  25. data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
  26. data/app/views/solid_observer/events/index.html.erb +53 -0
  27. data/app/views/solid_observer/events/show.html.erb +47 -0
  28. data/app/views/solid_observer/jobs/index.html.erb +61 -0
  29. data/app/views/solid_observer/jobs/show.html.erb +71 -0
  30. data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
  31. data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
  32. data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
  33. data/app/views/solid_observer/storages/show.html.erb +39 -0
  34. data/bin/quality_gate +95 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
  37. data/lib/generators/solid_observer/install_generator.rb +12 -25
  38. data/lib/generators/solid_observer/templates/initializer.rb.tt +5 -6
  39. data/lib/solid_observer/base_metric.rb +1 -1
  40. data/lib/solid_observer/chart_buffer.rb +83 -0
  41. data/lib/solid_observer/cli/base.rb +2 -2
  42. data/lib/solid_observer/cli/jobs.rb +2 -2
  43. data/lib/solid_observer/cli/status.rb +20 -2
  44. data/lib/solid_observer/cli/storage.rb +41 -40
  45. data/lib/solid_observer/configuration.rb +47 -37
  46. data/lib/solid_observer/correlation_id_resolver.rb +8 -6
  47. data/lib/solid_observer/engine.rb +72 -17
  48. data/lib/solid_observer/params/events_filter.rb +37 -0
  49. data/lib/solid_observer/params/jobs_filter.rb +35 -0
  50. data/lib/solid_observer/queries/events_query.rb +27 -0
  51. data/lib/solid_observer/queries/execution_finder.rb +42 -0
  52. data/lib/solid_observer/queries/job_executions_query.rb +73 -0
  53. data/lib/solid_observer/queue_event_buffer.rb +163 -25
  54. data/lib/solid_observer/queue_stats.rb +165 -19
  55. data/lib/solid_observer/services/cleanup_storage.rb +58 -42
  56. data/lib/solid_observer/services/database_size.rb +86 -0
  57. data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
  58. data/lib/solid_observer/services/install_migrations.rb +49 -0
  59. data/lib/solid_observer/services/record_event.rb +51 -14
  60. data/lib/solid_observer/services/ui_auth_check.rb +65 -0
  61. data/lib/solid_observer/subscriber.rb +15 -8
  62. data/lib/solid_observer/version.rb +1 -1
  63. data/lib/solid_observer.rb +7 -0
  64. data/lib/tasks/solid_observer.rake +10 -2
  65. 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