solid_observer 0.4.0 → 0.5.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +80 -20
  4. data/app/assets/javascripts/solid_observer/live_poll.js +3 -1
  5. data/app/controllers/solid_observer/application_controller.rb +1 -0
  6. data/app/controllers/solid_observer/cable_dashboard_controller.rb +52 -0
  7. data/app/controllers/solid_observer/cable_operations_controller.rb +16 -0
  8. data/app/controllers/solid_observer/cache_dashboard_controller.rb +33 -40
  9. data/app/controllers/solid_observer/dashboard_controller.rb +1 -7
  10. data/app/helpers/solid_observer/application_helper.rb +114 -0
  11. data/app/models/solid_observer/cable_event.rb +13 -0
  12. data/app/models/solid_observer/cable_metric.rb +12 -0
  13. data/app/models/solid_observer/cache_metric.rb +1 -2
  14. data/app/models/solid_observer/storage_info.rb +1 -1
  15. data/app/views/layouts/solid_observer/application.html.erb +19 -8
  16. data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
  17. data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +34 -0
  18. data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
  19. data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
  20. data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
  21. data/app/views/solid_observer/dashboard/index.html.erb +2 -5
  22. data/app/views/solid_observer/events/index.html.erb +1 -0
  23. data/app/views/solid_observer/jobs/index.html.erb +1 -0
  24. data/app/views/solid_observer/storages/show.html.erb +29 -3
  25. data/config/routes.rb +2 -0
  26. data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
  27. data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
  28. data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
  29. data/lib/generators/solid_observer/install_generator.rb +8 -1
  30. data/lib/generators/solid_observer/templates/initializer.rb.tt +18 -3
  31. data/lib/solid_observer/base_event.rb +1 -1
  32. data/lib/solid_observer/base_metric.rb +1 -1
  33. data/lib/solid_observer/base_record.rb +8 -0
  34. data/lib/solid_observer/cable_event_buffer.rb +28 -0
  35. data/lib/solid_observer/cable_metric_buffer.rb +230 -0
  36. data/lib/solid_observer/cable_subscriber.rb +57 -0
  37. data/lib/solid_observer/cache_event_buffer.rb +11 -36
  38. data/lib/solid_observer/cache_metric_buffer.rb +229 -0
  39. data/lib/solid_observer/chart_buffer.rb +84 -27
  40. data/lib/solid_observer/configuration.rb +47 -4
  41. data/lib/solid_observer/engine.rb +46 -28
  42. data/lib/solid_observer/event_buffer_core.rb +218 -0
  43. data/lib/solid_observer/queue_event_buffer.rb +9 -201
  44. data/lib/solid_observer/services/cable_operations.rb +74 -0
  45. data/lib/solid_observer/services/cable_stats.rb +385 -0
  46. data/lib/solid_observer/services/cache_stats.rb +35 -18
  47. data/lib/solid_observer/services/cleanup_storage.rb +82 -47
  48. data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
  49. data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
  50. data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
  51. data/lib/solid_observer/services/record_cable_event.rb +114 -0
  52. data/lib/solid_observer/services/record_cable_metric.rb +73 -0
  53. data/lib/solid_observer/services/record_cache_event.rb +23 -0
  54. data/lib/solid_observer/services/record_cache_metric.rb +13 -21
  55. data/lib/solid_observer/services/storage_info_snapshot.rb +103 -15
  56. data/lib/solid_observer/version.rb +1 -1
  57. data/lib/solid_observer.rb +36 -11
  58. data/lib/tasks/solid_observer.rake +84 -23
  59. metadata +26 -6
  60. data/app/assets/stylesheets/solid_observer/application.css +0 -18
  61. data/bin/console +0 -11
  62. data/bin/quality_gate +0 -95
  63. data/bin/setup +0 -8
@@ -14,8 +14,8 @@
14
14
 
15
15
  /* text */
16
16
  --so-text: #171717;
17
- --so-text-muted: #737373;
18
- --so-text-subtle: #737373;
17
+ --so-text-muted: #707070;
18
+ --so-text-subtle: #707070;
19
19
 
20
20
  /* lines */
21
21
  --so-border: #e5e5e5;
@@ -398,7 +398,8 @@
398
398
  .so-cache-controls { max-width: 820px; }
399
399
  .so-cache-dashboard__intro,
400
400
  .so-cache-controls__intro,
401
- .so-queue-overview__intro {
401
+ .so-queue-overview__intro,
402
+ .so-cable-dashboard__intro {
402
403
  display: flex;
403
404
  flex-wrap: wrap;
404
405
  align-items: center;
@@ -407,16 +408,19 @@
407
408
  }
408
409
  .so-cache-dashboard__hint,
409
410
  .so-cache-controls__hint,
410
- .so-queue-overview__hint {
411
+ .so-queue-overview__hint,
412
+ .so-cable-dashboard__hint {
411
413
  font-size: 0.875rem;
412
414
  color: var(--so-text-subtle);
413
415
  line-height: 1.5;
414
416
  }
415
- .so-cache-dashboard__range-form {
417
+ .so-cache-dashboard__range-form,
418
+ .so-cable-dashboard__range-form {
416
419
  align-items: center;
417
420
  margin-bottom: 2rem;
418
421
  }
419
422
  .so-cache-dashboard__range-form select,
423
+ .so-cable-dashboard__range-form select,
420
424
  .so-dashboard-toolbar select {
421
425
  padding: 0.4rem 0.6rem;
422
426
  border: 1px solid var(--so-border);
@@ -425,11 +429,13 @@
425
429
  background: var(--so-card-bg);
426
430
  color: var(--so-text);
427
431
  }
428
- .so-cache-dashboard__chart-empty {
432
+ .so-cache-dashboard__chart-empty,
433
+ .so-cable-dashboard__chart-empty {
429
434
  max-width: 420px;
430
435
  margin-bottom: 0;
431
436
  }
432
- .so-cache-dashboard__digest {
437
+ .so-cache-dashboard__digest,
438
+ .so-cable-dashboard__digest {
433
439
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
434
440
  white-space: nowrap;
435
441
  }
@@ -564,7 +570,12 @@
564
570
  <% end %>
565
571
  <% end %>
566
572
 
567
- <% if persistence_mode? && (SolidObserver.config.solid_queue_enabled? || SolidObserver.config.solid_cache_enabled?) %>
573
+ <% if SolidObserver.config.solid_cable_enabled? %>
574
+ <div class="so-sidebar__section">Cable</div>
575
+ <%= link_to "Overview", cable_dashboard_path, class: ("active" if controller_name == "cable_dashboard"), "aria-current": (controller_name == "cable_dashboard" ? "page" : nil) %>
576
+ <% end %>
577
+
578
+ <% if persistence_mode? && (SolidObserver.config.solid_queue_enabled? || SolidObserver.config.solid_cache_enabled? || SolidObserver.config.solid_cable_enabled?) %>
568
579
  <%= link_to "Storage", storage_path, class: ("active" if controller_name == "storages"), "aria-current": (controller_name == "storages" ? "page" : nil) %>
569
580
  <% end %>
570
581
  </nav>
@@ -0,0 +1,31 @@
1
+ <section class="so-dashboard-section" aria-labelledby="so-cable-charts-heading">
2
+ <header class="so-dashboard-section__header">
3
+ <h2 id="so-cable-charts-heading" class="so-dashboard-section__title">Activity trends</h2>
4
+ </header>
5
+
6
+ <div class="so-chart-strip">
7
+ <% if @stats[:activity_trends]&.[](:available) %>
8
+ <figure class="so-spark" data-so-spark="cable-broadcasts">
9
+ <figcaption class="so-spark__label">Broadcasts total <span data-so-range-copy><%= cable_range_label(@range) %></span></figcaption>
10
+ <svg class="so-spark__svg" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true">
11
+ <line class="so-spark__baseline" x1="1" x2="119" y1="31" y2="31"/>
12
+ <polyline class="so-spark__line" points="<%= spark_points(@stats[:activity_trends][:broadcasts]) %>"/>
13
+ </svg>
14
+ <span class="so-spark__value"><%= number_with_delimiter(@stats[:broadcasts_count].to_i) %></span>
15
+ </figure>
16
+
17
+ <figure class="so-spark" data-so-spark="cable-rejections">
18
+ <figcaption class="so-spark__label">Rejections total <span data-so-range-copy><%= cable_range_label(@range) %></span></figcaption>
19
+ <svg class="so-spark__svg" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true">
20
+ <line class="so-spark__baseline" x1="1" x2="119" y1="31" y2="31"/>
21
+ <polyline class="so-spark__line" points="<%= spark_points(@stats[:activity_trends][:rejections]) %>"/>
22
+ </svg>
23
+ <span class="so-spark__value"><%= number_with_delimiter(@stats[:rejections_count].to_i) %></span>
24
+ </figure>
25
+ <% else %>
26
+ <div class="so-card so-cable-dashboard__chart-empty">
27
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-range">No chart data in the selected range yet. Summary metrics still use bounded cable stats.</p>
28
+ </div>
29
+ <% end %>
30
+ </div>
31
+ </section>
@@ -0,0 +1,34 @@
1
+ <section class="so-card so-card--section" aria-labelledby="so-cable-events-heading">
2
+ <header class="so-dashboard-section__header">
3
+ <h2 id="so-cable-events-heading" class="so-dashboard-section__title">Recent Cable events</h2>
4
+ <span class="so-dashboard-section__meta">debug context only · broadcasting names and payloads are never shown</span>
5
+ </header>
6
+
7
+ <% if recent_events.present? %>
8
+ <table class="so-table so-table--card">
9
+ <caption class="sr-only">Recent Cable events without raw broadcasting names or payloads</caption>
10
+ <thead>
11
+ <tr>
12
+ <th>Event</th>
13
+ <th>Channel class</th>
14
+ <th>Broadcasting digest</th>
15
+ <th>Duration</th>
16
+ <th>Recorded</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% recent_events.each do |event| %>
21
+ <tr>
22
+ <td><%= event.event_type.to_s %></td>
23
+ <td><%= event.channel_class.presence || "—" %></td>
24
+ <td><span class="so-cable-dashboard__digest"><%= cable_event_digest(event.broadcasting_digest) %></span></td>
25
+ <td><%= event.duration ? format_duration(event.duration) : "—" %></td>
26
+ <td><span title="<%= event.recorded_at.utc.strftime("%Y-%m-%d %H:%M:%S UTC") %>"><%= time_ago_in_words(event.recorded_at) %> ago</span></td>
27
+ </tr>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ <% else %>
32
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-events">No sampled cable events in the selected range yet. Broadcasts, rejections, and errors will appear here after Cable activity is recorded.</p>
33
+ <% end %>
34
+ </section>
@@ -0,0 +1,34 @@
1
+ <% backlog_summary = cable_backlog_summary(stats) %>
2
+ <% storage_summary = cable_storage_summary(storage_components) %>
3
+
4
+ <section class="so-dashboard-section" aria-labelledby="so-cable-summary-heading">
5
+ <header class="so-dashboard-section__header">
6
+ <h2 id="so-cable-summary-heading" class="so-dashboard-section__title">Summary in selected range</h2>
7
+ </header>
8
+
9
+ <div class="so-stat-cards so-cable-dashboard__summary">
10
+ <article class="so-card so-metric">
11
+ <div class="so-card__label">Broadcasts</div>
12
+ <div class="so-metric__value"><%= number_with_delimiter(stats[:broadcasts_count].to_i) %></div>
13
+ <div class="so-card__subtitle">selected window</div>
14
+ </article>
15
+
16
+ <article class="so-card so-metric">
17
+ <div class="so-card__label">Rejection rate</div>
18
+ <div class="so-metric__value"><%= cable_ratio_percent(stats[:rejection_rate]) %></div>
19
+ <div class="so-card__subtitle">rejections / broadcasts</div>
20
+ </article>
21
+
22
+ <article class="so-card so-metric">
23
+ <div class="so-card__label">Message backlog</div>
24
+ <div class="so-metric__value"><%= backlog_summary[:value] %></div>
25
+ <div class="so-card__subtitle"><%= backlog_summary[:subtitle] %></div>
26
+ </article>
27
+
28
+ <article class="so-card so-metric">
29
+ <div class="so-card__label">Storage footprint</div>
30
+ <div class="so-metric__value"><%= storage_summary[:value] %></div>
31
+ <div class="so-card__subtitle"><%= storage_summary[:subtitle] %></div>
32
+ </article>
33
+ </div>
34
+ </section>
@@ -0,0 +1,118 @@
1
+ <% content_for :title, "Cable overview" %>
2
+ <% status_class = @cable_dashboard_available ? "so-badge--success" : "so-badge--warning" %>
3
+
4
+ <div class="so-dashboard so-cable-dashboard">
5
+ <span class="sr-only">Cable Dashboard</span>
6
+
7
+ <div class="so-content__header">
8
+ <h1>Cable overview</h1>
9
+
10
+ <% if @cable_dashboard_available %>
11
+ <div class="so-cable-dashboard__intro">
12
+ <span class="so-badge so-badge--pill <%= status_class %>">
13
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
14
+ <circle r="3" cx="3" cy="3" />
15
+ </svg>
16
+ Available
17
+ </span>
18
+ <p class="so-cable-dashboard__hint">Selected range: <%= cable_range_label(@range) %> · broadcasting names and payloads are never shown.</p>
19
+ </div>
20
+ <% end %>
21
+ </div>
22
+
23
+ <% if @cable_dashboard_available %>
24
+ <form action="<%= request.path %>" method="get" class="so-dashboard-toolbar so-cable-dashboard__range-form">
25
+ <label for="so-cable-range" class="so-card__label so-filters__label">Range</label>
26
+ <select id="so-cable-range" name="range" onchange="this.form.submit()">
27
+ <% SolidObserver::Services::CableStats::RANGES.each_key do |key| %>
28
+ <option value="<%= key %>" <%= "selected" if key == @range %>><%= cable_range_label(key) %></option>
29
+ <% end %>
30
+ </select>
31
+ <button type="submit" class="so-btn so-btn--refresh">Refresh data</button>
32
+ </form>
33
+
34
+ <%= render "solid_observer/cable_dashboard/summary", stats: @stats, storage_components: @storage_components %>
35
+
36
+ <section class="so-card so-card--section" aria-labelledby="so-cable-controls-heading">
37
+ <header class="so-dashboard-section__header">
38
+ <h2 id="so-cable-controls-heading" class="so-dashboard-section__title">Operational controls</h2>
39
+ </header>
40
+
41
+ <% cable_controls_available = SolidObserver::Services::CableOperations.available? && @stats&.[](:backlog_available) %>
42
+ <% cable_backlog_count = @stats&.[](:backlog_count).to_i %>
43
+
44
+ <% if cable_controls_available %>
45
+ <% if cable_backlog_count > 1000 %>
46
+ <div class="so-cache-control-row">
47
+ <div class="so-cache-control-row__copy">
48
+ <h3 class="so-cache-control-row__title">Use the Rake task</h3>
49
+ <p class="so-cache-control-row__body">More than 1,000 expired/trimmable Solid Cable messages are pending. The UI will not run this trim. Run solid_observer:cable:trim from the server instead.</p>
50
+ <span class="so-badge so-badge--pill so-badge--warning">
51
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
52
+ <circle r="3" cx="3" cy="3" />
53
+ </svg>
54
+ UI trim unavailable
55
+ </span>
56
+ </div>
57
+ </div>
58
+ <% else %>
59
+ <div class="so-cache-control-row">
60
+ <div class="so-cache-control-row__copy">
61
+ <h3 class="so-cache-control-row__title">Trim expired messages</h3>
62
+ <p class="so-cache-control-row__body">Removes expired/trimmable Solid Cable messages only. Active messages remain available to Cable.</p>
63
+ <span class="so-badge so-badge--pill so-badge--success">
64
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
65
+ <circle r="3" cx="3" cy="3" />
66
+ </svg>
67
+ Trimmable backlog: <%= cable_backlog_count %>
68
+ </span>
69
+ </div>
70
+
71
+ <div class="so-cache-control-row__action">
72
+ <%= button_to "Trim expired messages",
73
+ trim_cable_operations_path,
74
+ method: :post,
75
+ class: "so-btn",
76
+ form: {class: "so-form--inline"} %>
77
+ </div>
78
+ </div>
79
+ <% end %>
80
+ <% else %>
81
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--idle">Cable controls are unavailable because Solid Cable support is disabled or not detected. No trim was attempted.</p>
82
+ <span class="so-badge so-badge--pill so-badge--warning">
83
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
84
+ <circle r="3" cx="3" cy="3" />
85
+ </svg>
86
+ Unavailable
87
+ </span>
88
+ <% end %>
89
+ </section>
90
+
91
+ <%= render "solid_observer/cable_dashboard/charts" %>
92
+
93
+ <% if @stats&.[](:stability)&.[](:available) %>
94
+ <section class="so-stability" data-so-zone="cable-stability" aria-labelledby="so-cable-stability-heading">
95
+ <span class="so-stability__label" id="so-cable-stability-heading">Stability</span>
96
+ <%= cable_stability_badge(@stats[:stability][:state]) %>
97
+ <span class="so-stability__detail"><%= cable_stability_detail(@stats[:stability]) %></span>
98
+ <a href="#so-cable-events-heading" class="so-stability__link">View recent events →</a>
99
+ </section>
100
+ <% end %>
101
+
102
+ <%= render "solid_observer/cable_dashboard/recent_events", recent_events: @recent_events %>
103
+ <% else %>
104
+ <section class="so-card so-card--section" aria-labelledby="so-cable-unavailable-heading">
105
+ <header class="so-dashboard-section__header">
106
+ <h2 id="so-cable-unavailable-heading" class="so-dashboard-section__title">Cable dashboard unavailable</h2>
107
+ <span class="so-badge so-badge--pill <%= status_class %>">
108
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
109
+ <circle r="3" cx="3" cy="3" />
110
+ </svg>
111
+ Unavailable
112
+ </span>
113
+ </header>
114
+
115
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--idle">Cable dashboard is unavailable because Solid Cable support is disabled or not detected. Metrics are unavailable.</p>
116
+ </section>
117
+ <% end %>
118
+ </div>
@@ -8,6 +8,7 @@
8
8
  <% all_queue_names = (queues.keys | performed_by_queue.keys | failed_by_queue.keys).sort %>
9
9
  <% if all_queue_names.any? %>
10
10
  <table class="so-table so-table--card">
11
+ <caption class="sr-only">Queue throughput list</caption>
11
12
  <thead>
12
13
  <tr>
13
14
  <th>Queue</th>
@@ -1,8 +1,5 @@
1
1
  <% content_for :title, "Dashboard" %>
2
2
 
3
- <% if @component == "cache" %>
4
- <%= render template: "solid_observer/cache_dashboard/index" %>
5
- <% else %>
6
3
  <div class="so-dashboard">
7
4
  <% queue_enabled = SolidObserver.config.solid_queue_enabled? %>
8
5
  <% queue_status_class = queue_enabled ? "so-badge--success" : "so-badge--warning" %>
@@ -29,7 +26,7 @@
29
26
  </section>
30
27
  <% else %>
31
28
  <%# Zone A: Toolbar — range selector, Refresh, Live toggle, help disclosure %>
32
- <div class="so-dashboard-toolbar" data-so-live>
29
+ <div class="so-dashboard-toolbar" data-so-live data-so-poll-url="<%= poll_data_path %>">
33
30
  <form action="<%= request.path %>" method="get" class="so-dashboard-toolbar__left">
34
31
  <label for="so-range" class="so-card__label so-filters__label">Range</label>
35
32
  <select id="so-range" name="range" data-so-range-select onchange="this.form.submit()">
@@ -111,6 +108,7 @@
111
108
  <div class="so-card">
112
109
  <div class="so-card__label">Recent Events</div>
113
110
  <table class="so-table so-table--card">
111
+ <caption class="sr-only">Recent queue events</caption>
114
112
  <thead>
115
113
  <tr>
116
114
  <th>Event</th>
@@ -140,4 +138,3 @@
140
138
  <% end %>
141
139
  <% end %>
142
140
  </div>
143
- <% end %>
@@ -25,6 +25,7 @@
25
25
 
26
26
  <% if @events.any? %>
27
27
  <table class="so-table so-table--listing">
28
+ <caption class="sr-only">Events list</caption>
28
29
  <thead>
29
30
  <tr>
30
31
  <th>Event</th>
@@ -20,6 +20,7 @@
20
20
 
21
21
  <% if @jobs.any? %>
22
22
  <table class="so-table so-table--listing">
23
+ <caption class="sr-only">Jobs list</caption>
23
24
  <thead>
24
25
  <tr>
25
26
  <th>ID</th>
@@ -28,7 +28,24 @@
28
28
  <div class="so-card__value"><%= size_value %></div>
29
29
  <div class="so-card__subtitle">
30
30
  <% if component[:available] %>
31
- <%= records_value %> <%= component[:record_label] %>
31
+ <% if component[:component] == "solid_cable" %>
32
+ <% parts = ["#{records_value} #{component[:record_label]}"] %>
33
+ <% parts << "#{number_with_delimiter(component[:trimmable_count])} trimmable" unless component[:trimmable_count].nil? %>
34
+ <% unless component[:oldest_message_age_seconds].nil? %>
35
+ <% age = component[:oldest_message_age_seconds] %>
36
+ <% if age < 3600 %>
37
+ <% value = [age / 60, 1].max; unit = "m" %>
38
+ <% elsif age < 86_400 %>
39
+ <% value = age / 3600; unit = "h" %>
40
+ <% else %>
41
+ <% value = age / 86_400; unit = "d" %>
42
+ <% end %>
43
+ <% parts << "oldest #{value}#{unit}" %>
44
+ <% end %>
45
+ <%= parts.join(" · ") %>
46
+ <% else %>
47
+ <%= records_value %> <%= component[:record_label] %>
48
+ <% end %>
32
49
  <% else %>
33
50
  <%= component[:unavailable_reason] %>
34
51
  <% end %>
@@ -53,10 +70,19 @@
53
70
  </thead>
54
71
  <tbody>
55
72
  <% @storage_history.each do |snapshot| %>
56
- <% record_label = snapshot.component == "solid_cache" ? "cache rows" : "observer events" %>
73
+ <% record_label = case snapshot.component
74
+ when "solid_cache" then "cache rows"
75
+ when "solid_cable" then "messages"
76
+ else "observer events"
77
+ end %>
78
+ <% component_label = case snapshot.component
79
+ when "solid_cable" then "Solid Cable messages"
80
+ when "cable_observer" then "Cable telemetry"
81
+ else snapshot.component.humanize
82
+ end %>
57
83
  <tr>
58
84
  <td><%= snapshot.recorded_at.strftime("%Y-%m-%d %H:%M") %></td>
59
- <td><%= snapshot.component.humanize %></td>
85
+ <td><%= component_label %></td>
60
86
  <td><%= number_to_human_size(snapshot.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %></td>
61
87
  <td><%= number_with_delimiter(snapshot.event_count) %> <%= record_label %></td>
62
88
  </tr>
data/config/routes.rb CHANGED
@@ -4,6 +4,8 @@ SolidObserver::Engine.routes.draw do
4
4
  root "dashboard#index"
5
5
  get "queue", to: "dashboard#index", defaults: {component: "queue"}, as: :queue_dashboard
6
6
  get "cache", to: "cache_dashboard#index", as: :cache_dashboard
7
+ get "cable", to: "cable_dashboard#index", as: :cable_dashboard
8
+ post "cable/trim", to: "cable_operations#trim", as: :trim_cable_operations
7
9
  get "cache/controls", to: "cache_operations#index", as: :cache_operations
8
10
  post "cache/controls/prune", to: "cache_operations#prune", as: :prune_cache_operations
9
11
  post "cache/controls/clear", to: "cache_operations#clear", as: :clear_cache_operations
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddEventTypeRecordedAtIndexToCacheEvents < ActiveRecord::Migration[8.0]
4
+ disable_ddl_transaction!
5
+
6
+ def change
7
+ options = concurrent_supported? ? {algorithm: :concurrently} : {}
8
+
9
+ add_index :solid_observer_cache_events,
10
+ %i[event_type recorded_at],
11
+ order: {recorded_at: :desc},
12
+ if_not_exists: true,
13
+ **options
14
+ end
15
+
16
+ private
17
+
18
+ def concurrent_supported?
19
+ connection.adapter_name.match?(/postgres/i)
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidObserverCableEvents < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :solid_observer_cable_events do |t|
6
+ t.string :event_type, null: false, limit: 64
7
+ t.string :channel_class, limit: 255
8
+ t.string :broadcasting_digest, limit: 64
9
+ t.float :duration
10
+ t.string :error_class, limit: 255
11
+ t.text :error_message
12
+ t.text :metadata
13
+ t.datetime :recorded_at, null: false
14
+
15
+ t.index :recorded_at
16
+ t.index :event_type
17
+ t.index :channel_class
18
+ t.index :broadcasting_digest
19
+ t.index :error_class
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidObserverCableMetrics < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :solid_observer_cable_metrics do |t|
6
+ t.datetime :period_start, null: false
7
+ t.bigint :broadcasts_count, null: false, default: 0
8
+ t.bigint :transmissions_count, null: false, default: 0
9
+ t.bigint :confirmations_count, null: false, default: 0
10
+ t.bigint :rejections_count, null: false, default: 0
11
+ t.bigint :perform_actions_count, null: false, default: 0
12
+ t.bigint :errors_count, null: false, default: 0
13
+
14
+ t.index :period_start, unique: true, name: "idx_solid_observer_cable_metrics_unique"
15
+ end
16
+ end
17
+ end
@@ -17,8 +17,11 @@ module SolidObserver
17
17
  %w[development test production].each do |env|
18
18
  config_block = <<-YAML
19
19
  solid_observer_queue:
20
- <<: *default
20
+ adapter: sqlite3
21
+ pool: 5
22
+ timeout: 5000
21
23
  database: storage/#{env}_solid_observer_queue.sqlite3
24
+ migrations_paths: db/solid_observer_migrate
22
25
  YAML
23
26
  inject_into_file "config/database.yml", config_block, after: /^#{env}:\n(?: .*\n)*/
24
27
  end
@@ -40,6 +43,10 @@ module SolidObserver
40
43
  say " 5. Restart your Rails server"
41
44
  say " 6. Visit /solid_observer to access the web dashboard"
42
45
  say "\n"
46
+ say "IMPORTANT: If your host app uses PostgreSQL or MySQL, review the", :yellow
47
+ say "solid_observer_queue entries in config/database.yml before running", :yellow
48
+ say "db:create. The generated config uses adapter: sqlite3 by default.", :yellow
49
+ say "\n"
43
50
  say "Documentation: https://solid.observer", :red
44
51
  say "GitHub: https://github.com/bart-oz/solid_observer", :red
45
52
  say "\n"
@@ -9,11 +9,20 @@ SolidObserver.configure do |config|
9
9
  # Recommended: false in production, true in development/staging
10
10
  config.ui_enabled = !Rails.env.production?
11
11
 
12
- # Authentication for web UI set a username to enable HTTP Basic Auth
12
+ # Production dashboard exposure is the host app's responsibility. If you mount
13
+ # the dashboard in production, wrap the mount in your existing admin auth
14
+ # constraint, for example in config/routes.rb:
15
+ #
16
+ # authenticate :user, ->(user) { user.admin? } do
17
+ # mount SolidObserver::Engine, at: "/solid_observer"
18
+ # end
19
+ #
20
+ # Built-in HTTP Basic Auth is enabled only when BOTH credentials are present.
21
+ # Setting only one credential disables Basic Auth and logs a boot warning.
13
22
  # config.ui_username = "admin"
14
23
  # config.ui_password = "secret"
15
24
 
16
- # Base controller for UI (customize authorization)
25
+ # Base controller used only to detect API-only rendering compatibility
17
26
  # config.ui_base_controller = "ApplicationController"
18
27
 
19
28
  # === Queue Observability (v0.1.0) ===
@@ -24,8 +33,14 @@ SolidObserver.configure do |config|
24
33
  # config.observe_cache = true
25
34
  # config.cache_sampling_rate = 0.1 # Sample 10% of cache operations
26
35
 
27
- # === Cable Observability (Coming in v0.5.0+) ===
36
+ # === Cable Observability (v0.5.0) ===
37
+ # Enable SolidCable event capture and trim controls.
38
+ # Requires SolidCable in the host app; SolidObserver does not add it.
28
39
  # config.observe_cable = true
40
+ # config.cable_sampling_rate = 0.1 # Sample 10% of broadcast events
41
+ # config.cable_rejection_threshold = 0.05 # Rejection rate → Degraded stability
42
+ # config.cable_backlog_threshold = 0.10 # Backlog ratio threshold
43
+ # config.cable_error_threshold = 0.0 # Error rate threshold
29
44
 
30
45
  # Data Retention
31
46
  config.event_retention = 30.days # How long to keep event records
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidObserver
4
- class BaseEvent < ActiveRecord::Base
4
+ class BaseEvent < BaseRecord
5
5
  self.abstract_class = true
6
6
 
7
7
  # connects_to is configured by the engine after Rails initializes
@@ -7,7 +7,7 @@ module SolidObserver
7
7
  # will be configured by the Engine (similar to BaseEvent) when metrics are
8
8
  # fully implemented.
9
9
  #
10
- class BaseMetric < ActiveRecord::Base
10
+ class BaseMetric < BaseRecord
11
11
  self.abstract_class = true
12
12
  self.table_name = "solid_observer_metrics"
13
13
 
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class BaseRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ # connects_to is configured by the engine after Rails initializes
7
+ end
8
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ require_relative "event_buffer_core"
6
+
7
+ module SolidObserver
8
+ class CableEventBuffer
9
+ include Singleton
10
+ include EventBufferCore
11
+
12
+ INITIAL_METRICS = EventBufferCore::INITIAL_METRICS
13
+
14
+ def initialize
15
+ initialize_event_buffer
16
+ end
17
+
18
+ private
19
+
20
+ def flush_service
21
+ Services::FlushCableEventBuffer
22
+ end
23
+
24
+ def log_label
25
+ "Cable buffer"
26
+ end
27
+ end
28
+ end