solid_observer 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +100 -25
  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 +21 -13
  10. data/app/controllers/solid_observer/traces_controller.rb +17 -0
  11. data/app/helpers/solid_observer/application_helper.rb +145 -3
  12. data/app/models/solid_observer/cable_event.rb +13 -0
  13. data/app/models/solid_observer/cable_metric.rb +12 -0
  14. data/app/models/solid_observer/cache_metric.rb +1 -2
  15. data/app/models/solid_observer/storage_info.rb +1 -1
  16. data/app/views/layouts/solid_observer/application.html.erb +35 -9
  17. data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
  18. data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +39 -0
  19. data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
  20. data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
  21. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +6 -1
  22. data/app/views/solid_observer/dashboard/_component_cards.html.erb +18 -0
  23. data/app/views/solid_observer/dashboard/_health.html.erb +6 -0
  24. data/app/views/solid_observer/dashboard/_home.html.erb +10 -0
  25. data/app/views/solid_observer/dashboard/_queue.html.erb +138 -0
  26. data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
  27. data/app/views/solid_observer/dashboard/_unified_feed.html.erb +38 -0
  28. data/app/views/solid_observer/dashboard/index.html.erb +3 -139
  29. data/app/views/solid_observer/events/index.html.erb +7 -1
  30. data/app/views/solid_observer/jobs/index.html.erb +1 -0
  31. data/app/views/solid_observer/storages/show.html.erb +29 -3
  32. data/app/views/solid_observer/traces/show.html.erb +80 -0
  33. data/config/routes.rb +3 -0
  34. data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
  35. data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
  36. data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
  37. data/db/migrate/20260630000001_add_correlation_id_to_cache_events.rb +8 -0
  38. data/db/migrate/20260630000002_add_correlation_id_to_cable_events.rb +8 -0
  39. data/lib/generators/solid_observer/install_generator.rb +8 -1
  40. data/lib/generators/solid_observer/templates/initializer.rb.tt +24 -5
  41. data/lib/solid_observer/base_event.rb +1 -1
  42. data/lib/solid_observer/base_metric.rb +1 -1
  43. data/lib/solid_observer/base_record.rb +8 -0
  44. data/lib/solid_observer/cable_event_buffer.rb +28 -0
  45. data/lib/solid_observer/cable_metric_buffer.rb +230 -0
  46. data/lib/solid_observer/cable_subscriber.rb +57 -0
  47. data/lib/solid_observer/cache_event_buffer.rb +11 -36
  48. data/lib/solid_observer/cache_metric_buffer.rb +229 -0
  49. data/lib/solid_observer/chart_buffer.rb +84 -27
  50. data/lib/solid_observer/cli/health.rb +49 -0
  51. data/lib/solid_observer/cli/trace.rb +106 -0
  52. data/lib/solid_observer/configuration.rb +47 -7
  53. data/lib/solid_observer/correlated.rb +23 -0
  54. data/lib/solid_observer/engine.rb +46 -28
  55. data/lib/solid_observer/event_buffer_core.rb +218 -0
  56. data/lib/solid_observer/queries/trace_query.rb +151 -0
  57. data/lib/solid_observer/queue_event_buffer.rb +9 -201
  58. data/lib/solid_observer/services/cable_operations.rb +74 -0
  59. data/lib/solid_observer/services/cable_stats.rb +385 -0
  60. data/lib/solid_observer/services/cache_stats.rb +35 -18
  61. data/lib/solid_observer/services/cleanup_storage.rb +82 -47
  62. data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
  63. data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
  64. data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
  65. data/lib/solid_observer/services/health_score.rb +85 -0
  66. data/lib/solid_observer/services/record_cable_event.rb +115 -0
  67. data/lib/solid_observer/services/record_cable_metric.rb +73 -0
  68. data/lib/solid_observer/services/record_cache_event.rb +24 -0
  69. data/lib/solid_observer/services/record_cache_metric.rb +13 -21
  70. data/lib/solid_observer/services/storage_info_snapshot.rb +103 -15
  71. data/lib/solid_observer/services/unified_feed.rb +101 -0
  72. data/lib/solid_observer/version.rb +1 -1
  73. data/lib/solid_observer.rb +42 -11
  74. data/lib/tasks/solid_observer.rake +97 -21
  75. metadata +41 -6
  76. data/app/assets/stylesheets/solid_observer/application.css +0 -18
  77. data/bin/console +0 -11
  78. data/bin/quality_gate +0 -95
  79. data/bin/setup +0 -8
@@ -0,0 +1,138 @@
1
+ <div class="so-dashboard">
2
+ <% queue_enabled = SolidObserver.config.solid_queue_enabled? %>
3
+ <% queue_status_class = queue_enabled ? "so-badge--success" : "so-badge--warning" %>
4
+
5
+ <div class="so-content__header">
6
+ <h1>Queue overview</h1>
7
+ <div class="so-queue-overview__intro">
8
+ <span class="so-badge so-badge--pill <%= queue_status_class %>">
9
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
10
+ <circle r="3" cx="3" cy="3" />
11
+ </svg>
12
+ <%= queue_enabled ? "Available" : "Unavailable" %>
13
+ </span>
14
+ <% if queue_enabled %>
15
+ <p class="so-queue-overview__hint">Selected range: <%= range_label(@range) %></p>
16
+ <% end %>
17
+ </div>
18
+ </div>
19
+
20
+ <% if !queue_enabled %>
21
+ <section class="so-card so-card--section" aria-labelledby="so-queue-unavailable-heading">
22
+ <h2 id="so-queue-unavailable-heading">Queue Unavailable</h2>
23
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--idle">Queue component is disabled or not installed. Enable SolidQueue to access dashboard controls.</p>
24
+ </section>
25
+ <% else %>
26
+ <%# Zone A: Toolbar — range selector, Refresh, Live toggle, help disclosure %>
27
+ <div class="so-dashboard-toolbar" data-so-live data-so-poll-url="<%= poll_data_path %>">
28
+ <form action="<%= request.path %>" method="get" class="so-dashboard-toolbar__left">
29
+ <label for="so-range" class="so-card__label so-filters__label">Range</label>
30
+ <select id="so-range" name="range" data-so-range-select onchange="this.form.submit()">
31
+ <% SolidObserver::QueueStats::RANGES.each_key do |key| %>
32
+ <option value="<%= key %>" <%= "selected" if key == @range %>><%= key %></option>
33
+ <% end %>
34
+ </select>
35
+ <button type="button" class="so-btn so-btn--refresh" data-so-refresh aria-busy="false">Refresh data</button>
36
+ <span class="so-toolbar-freshness" data-so-freshness aria-live="polite"></span>
37
+ <input type="hidden" name="live" value="<%= @live ? 'on' : '' %>">
38
+ </form>
39
+ <div class="so-dashboard-toolbar__right">
40
+ <label class="so-toggle so-toggle--pill <%= "so-toggle--on" if @live %>">
41
+ <input type="checkbox" name="live" value="on" <%= "checked" if @live %> data-so-live-toggle>
42
+ <span class="so-toggle__track" aria-hidden="true">
43
+ <span class="so-toggle__thumb"></span>
44
+ </span>
45
+ <span class="so-toggle__label">Live</span>
46
+ <span class="so-toggle__sep" aria-hidden="true"> · </span>
47
+ <span class="so-toggle__cadence" aria-live="polite"><%= @live ? "5s" : "off" %></span>
48
+ <span class="so-toggle__dot" aria-hidden="true"></span>
49
+ </label>
50
+ <div class="so-help-wrapper" data-so-help-wrapper>
51
+ <button type="button" class="so-btn so-btn--help" data-so-help-btn aria-expanded="false" aria-controls="so-help-panel">(?)
52
+ </button>
53
+ <div id="so-help-panel" class="so-help-panel" data-so-help-panel hidden>
54
+ <p>Live mode polls every 5 seconds for current queue state. Throughput and chart data update only on range change or manual refresh.</p>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <%# Zone B: Right now — live state %>
61
+ <section class="so-dashboard-section" aria-labelledby="so-live-state-heading">
62
+ <div class="so-dashboard-section__header">
63
+ <h2 class="so-dashboard-section__title" id="so-live-state-heading">Right now</h2>
64
+ <span class="so-dashboard-section__meta"><span class="so-badge so-badge--pill so-badge--success"><svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true"><circle r="3" cx="3" cy="3" fill="var(--so-live-marker, var(--so-success))"/></svg></span> current</span>
65
+ </div>
66
+ <%= render "live_state", stats: @stats %>
67
+ </section>
68
+
69
+ <%# Zone C: Throughput in selected range %>
70
+ <% if persistence_mode? && @stats[:available] %>
71
+ <section class="so-dashboard-section" aria-labelledby="so-throughput-heading">
72
+ <div class="so-dashboard-section__header">
73
+ <h2 class="so-dashboard-section__title" id="so-throughput-heading">Throughput in selected range</h2>
74
+ <span class="so-dashboard-section__meta"><span class="so-badge so-badge--pill so-badge--info"><svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true"><circle r="3" cx="3" cy="3" fill="var(--so-range-marker, var(--so-info))"/></svg></span> <span data-so-range-copy><%= range_label(@range) %></span></span>
75
+ </div>
76
+ <%= render "throughput", stats: @stats, range: @range %>
77
+ </section>
78
+ <% end %>
79
+
80
+ <%# Zone D: Charts %>
81
+ <% if @stats[:available] %>
82
+ <section class="so-dashboard-section" aria-labelledby="so-chart-heading">
83
+ <div class="so-dashboard-section__header">
84
+ <h2 class="so-dashboard-section__title" id="so-chart-heading">Charts</h2>
85
+ </div>
86
+ <%= render "chart", range: @range %>
87
+ </section>
88
+ <% end %>
89
+
90
+ <%# Zone E: Per-queue table %>
91
+ <%= render "queue_table", stats: @stats, range: @range %>
92
+
93
+ <%# Zone F: Stability strip %>
94
+ <% if @stats[:available] && persistence_mode? %>
95
+ <section class="so-stability" data-so-zone="stability" aria-labelledby="so-stability-heading">
96
+ <span class="so-stability__label" id="so-stability-heading">Stability</span>
97
+ <%= stability_badge(@stats) %>
98
+ <span class="so-stability__detail"><%= stability_detail(@stats) %></span>
99
+ <%= link_to "View failures →", events_path(event_type: "job_failed"), class: "so-stability__link" %>
100
+ </section>
101
+ <% end %>
102
+
103
+ <%# Zone G: Recent Events %>
104
+ <% if @stats[:available] && persistence_mode? %>
105
+ <% if defined?(@recent_events) && @recent_events.present? %>
106
+ <div class="so-card">
107
+ <div class="so-card__label">Recent Events</div>
108
+ <table class="so-table so-table--card">
109
+ <caption class="sr-only">Recent queue events</caption>
110
+ <thead>
111
+ <tr>
112
+ <th>Event</th>
113
+ <th>Job Class</th>
114
+ <th>Queue</th>
115
+ <th>Time</th>
116
+ </tr>
117
+ </thead>
118
+ <tbody>
119
+ <% @recent_events.each do |event| %>
120
+ <tr>
121
+ <td><%= event.event_type.humanize %></td>
122
+ <td><%= event.job_class %></td>
123
+ <td><%= event.queue_name %></td>
124
+ <td><%= time_ago_in_words(event.recorded_at) %> ago</td>
125
+ </tr>
126
+ <% end %>
127
+ </tbody>
128
+ </table>
129
+ </div>
130
+ <% else %>
131
+ <div class="so-card">
132
+ <div class="so-card__label">Recent Events</div>
133
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-events">No recent events yet. Queue activity will appear here after jobs are enqueued, performed, or failed.</p>
134
+ </div>
135
+ <% end %>
136
+ <% end %>
137
+ <% end %>
138
+ </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>
@@ -0,0 +1,38 @@
1
+ <section class="so-card" data-so-zone="home-feed" aria-labelledby="so-feed-heading">
2
+ <div class="so-card__label" id="so-feed-heading">Recent Activity</div>
3
+ <% if SolidObserver.config.realtime_mode? %>
4
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-events">Feed requires persistence mode. Switch to persistence mode to see recent activity across components.</p>
5
+ <% elsif feed.present? %>
6
+ <table class="so-table so-table--card">
7
+ <caption class="sr-only">Unified recent activity across enabled components</caption>
8
+ <thead>
9
+ <tr>
10
+ <th>Component</th>
11
+ <th>Event</th>
12
+ <th>Detail</th>
13
+ <th>Time</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <% feed.each do |row| %>
18
+ <tr>
19
+ <td><%= row[:component].to_s.titleize %></td>
20
+ <td><%= row[:event_type].to_s.humanize %></td>
21
+ <td>
22
+ <%= unified_feed_detail(row) %>
23
+ </td>
24
+ <td>
25
+ <% if row[:recorded_at] %>
26
+ <time datetime="<%= row[:recorded_at].iso8601 %>" title="<%= row[:recorded_at].iso8601 %>"><%= time_ago_in_words(row[:recorded_at]) %> ago</time>
27
+ <% else %>
28
+
29
+ <% end %>
30
+ </td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ <% else %>
36
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-events">No recent activity yet. Events will appear here after components record activity.</p>
37
+ <% end %>
38
+ </section>
@@ -1,143 +1,7 @@
1
1
  <% content_for :title, "Dashboard" %>
2
2
 
3
- <% if @component == "cache" %>
4
- <%= render template: "solid_observer/cache_dashboard/index" %>
3
+ <% if @component == "home" %>
4
+ <%= render "home", health: @health, feed: @feed %>
5
5
  <% else %>
6
- <div class="so-dashboard">
7
- <% queue_enabled = SolidObserver.config.solid_queue_enabled? %>
8
- <% queue_status_class = queue_enabled ? "so-badge--success" : "so-badge--warning" %>
9
-
10
- <div class="so-content__header">
11
- <h1>Queue overview</h1>
12
- <div class="so-queue-overview__intro">
13
- <span class="so-badge so-badge--pill <%= queue_status_class %>">
14
- <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
15
- <circle r="3" cx="3" cy="3" />
16
- </svg>
17
- <%= queue_enabled ? "Available" : "Unavailable" %>
18
- </span>
19
- <% if queue_enabled %>
20
- <p class="so-queue-overview__hint">Selected range: <%= range_label(@range) %></p>
21
- <% end %>
22
- </div>
23
- </div>
24
-
25
- <% if !queue_enabled %>
26
- <section class="so-card so-card--section" aria-labelledby="so-queue-unavailable-heading">
27
- <h2 id="so-queue-unavailable-heading">Queue Unavailable</h2>
28
- <p class="so-dashboard-section__meta so-dashboard-section__meta--idle">Queue component is disabled or not installed. Enable SolidQueue to access dashboard controls.</p>
29
- </section>
30
- <% else %>
31
- <%# Zone A: Toolbar — range selector, Refresh, Live toggle, help disclosure %>
32
- <div class="so-dashboard-toolbar" data-so-live>
33
- <form action="<%= request.path %>" method="get" class="so-dashboard-toolbar__left">
34
- <label for="so-range" class="so-card__label so-filters__label">Range</label>
35
- <select id="so-range" name="range" data-so-range-select onchange="this.form.submit()">
36
- <% SolidObserver::QueueStats::RANGES.each_key do |key| %>
37
- <option value="<%= key %>" <%= "selected" if key == @range %>><%= key %></option>
38
- <% end %>
39
- </select>
40
- <button type="button" class="so-btn so-btn--refresh" data-so-refresh aria-busy="false">Refresh data</button>
41
- <span class="so-toolbar-freshness" data-so-freshness aria-live="polite"></span>
42
- <input type="hidden" name="live" value="<%= @live ? 'on' : '' %>">
43
- </form>
44
- <div class="so-dashboard-toolbar__right">
45
- <label class="so-toggle so-toggle--pill <%= "so-toggle--on" if @live %>">
46
- <input type="checkbox" name="live" value="on" <%= "checked" if @live %> data-so-live-toggle>
47
- <span class="so-toggle__track" aria-hidden="true">
48
- <span class="so-toggle__thumb"></span>
49
- </span>
50
- <span class="so-toggle__label">Live</span>
51
- <span class="so-toggle__sep" aria-hidden="true"> · </span>
52
- <span class="so-toggle__cadence" aria-live="polite"><%= @live ? "5s" : "off" %></span>
53
- <span class="so-toggle__dot" aria-hidden="true"></span>
54
- </label>
55
- <div class="so-help-wrapper" data-so-help-wrapper>
56
- <button type="button" class="so-btn so-btn--help" data-so-help-btn aria-expanded="false" aria-controls="so-help-panel">(?)
57
- </button>
58
- <div id="so-help-panel" class="so-help-panel" data-so-help-panel hidden>
59
- <p>Live mode polls every 5 seconds for current queue state. Throughput and chart data update only on range change or manual refresh.</p>
60
- </div>
61
- </div>
62
- </div>
63
- </div>
64
-
65
- <%# Zone B: Right now — live state %>
66
- <section class="so-dashboard-section" aria-labelledby="so-live-state-heading">
67
- <div class="so-dashboard-section__header">
68
- <h2 class="so-dashboard-section__title" id="so-live-state-heading">Right now</h2>
69
- <span class="so-dashboard-section__meta"><span class="so-badge so-badge--pill so-badge--success"><svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true"><circle r="3" cx="3" cy="3" fill="var(--so-live-marker, var(--so-success))"/></svg></span> current</span>
70
- </div>
71
- <%= render "live_state", stats: @stats %>
72
- </section>
73
-
74
- <%# Zone C: Throughput in selected range %>
75
- <% if persistence_mode? && @stats[:available] %>
76
- <section class="so-dashboard-section" aria-labelledby="so-throughput-heading">
77
- <div class="so-dashboard-section__header">
78
- <h2 class="so-dashboard-section__title" id="so-throughput-heading">Throughput in selected range</h2>
79
- <span class="so-dashboard-section__meta"><span class="so-badge so-badge--pill so-badge--info"><svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true"><circle r="3" cx="3" cy="3" fill="var(--so-range-marker, var(--so-info))"/></svg></span> <span data-so-range-copy><%= range_label(@range) %></span></span>
80
- </div>
81
- <%= render "throughput", stats: @stats, range: @range %>
82
- </section>
83
- <% end %>
84
-
85
- <%# Zone D: Charts %>
86
- <% if @stats[:available] %>
87
- <section class="so-dashboard-section" aria-labelledby="so-chart-heading">
88
- <div class="so-dashboard-section__header">
89
- <h2 class="so-dashboard-section__title" id="so-chart-heading">Charts</h2>
90
- </div>
91
- <%= render "chart", range: @range %>
92
- </section>
93
- <% end %>
94
-
95
- <%# Zone E: Per-queue table %>
96
- <%= render "queue_table", stats: @stats, range: @range %>
97
-
98
- <%# Zone F: Stability strip %>
99
- <% if @stats[:available] && persistence_mode? %>
100
- <section class="so-stability" data-so-zone="stability" aria-labelledby="so-stability-heading">
101
- <span class="so-stability__label" id="so-stability-heading">Stability</span>
102
- <%= stability_badge(@stats) %>
103
- <span class="so-stability__detail"><%= stability_detail(@stats) %></span>
104
- <%= link_to "View failures →", events_path(event_type: "job_failed"), class: "so-stability__link" %>
105
- </section>
106
- <% end %>
107
-
108
- <%# Zone G: Recent Events %>
109
- <% if @stats[:available] && persistence_mode? %>
110
- <% if defined?(@recent_events) && @recent_events.present? %>
111
- <div class="so-card">
112
- <div class="so-card__label">Recent Events</div>
113
- <table class="so-table so-table--card">
114
- <thead>
115
- <tr>
116
- <th>Event</th>
117
- <th>Job Class</th>
118
- <th>Queue</th>
119
- <th>Time</th>
120
- </tr>
121
- </thead>
122
- <tbody>
123
- <% @recent_events.each do |event| %>
124
- <tr>
125
- <td><%= event.event_type.humanize %></td>
126
- <td><%= event.job_class %></td>
127
- <td><%= event.queue_name %></td>
128
- <td><%= time_ago_in_words(event.recorded_at) %> ago</td>
129
- </tr>
130
- <% end %>
131
- </tbody>
132
- </table>
133
- </div>
134
- <% else %>
135
- <div class="so-card">
136
- <div class="so-card__label">Recent Events</div>
137
- <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-events">No recent events yet. Queue activity will appear here after jobs are enqueued, performed, or failed.</p>
138
- </div>
139
- <% end %>
140
- <% end %>
141
- <% end %>
142
- </div>
6
+ <%= render "queue" %>
143
7
  <% 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>
@@ -37,7 +38,12 @@
37
38
  <tbody>
38
39
  <% @events.each do |event| %>
39
40
  <tr>
40
- <td><%= link_to event.event_type.humanize, event_path(event.id) %></td>
41
+ <td>
42
+ <%= link_to event.event_type.humanize, event_path(event.id) %>
43
+ <% if event.correlation_id.present? %>
44
+ <%= link_to "Trace →", trace_path(event.correlation_id), class: "so-trace__link" %>
45
+ <% end %>
46
+ </td>
41
47
  <td><%= event.job_class %></td>
42
48
  <td><%= event.queue_name %></td>
43
49
  <td><%= duration_with_semantic(event.duration, event.event_type) %></td>
@@ -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>
@@ -0,0 +1,80 @@
1
+ <% content_for :title, "Trace #{params[:id]}" %>
2
+
3
+ <div class="so-trace">
4
+ <div class="so-back-link">
5
+ <%= link_to "← Back to Events", events_path, class: "so-btn" %>
6
+ </div>
7
+
8
+ <div class="so-content__header">
9
+ <h1>Trace <span class="so-trace__correlation"><%= params[:id] %></span></h1>
10
+ </div>
11
+
12
+ <% if @rows.any? %>
13
+ <p class="so-trace__meta">Showing newest <%= @rows.size %> events (maximum 100)</p>
14
+ <% end %>
15
+
16
+ <% if @unavailable_components.any? %>
17
+ <div class="so-flash so-flash--notice" role="status">
18
+ Some components could not be read: <%= @unavailable_components.map(&:to_s).sort.join(", ") %>. Showing available events only.
19
+ </div>
20
+ <% end %>
21
+
22
+ <% if @rows.any? %>
23
+ <% component_tones = {queue: "info", cache: "success", cable: "warning"} %>
24
+ <table class="so-table so-table--listing so-trace__table">
25
+ <caption class="sr-only">Trace timeline for correlation ID <%= params[:id] %></caption>
26
+ <thead>
27
+ <tr>
28
+ <th class="so-trace__time">Time</th>
29
+ <th class="so-trace__component">Component</th>
30
+ <th class="so-trace__event">Event</th>
31
+ <th class="so-trace__details">Details</th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ <% @rows.each do |row| %>
36
+ <tr>
37
+ <td class="so-trace__time"><%= row[:recorded_at].strftime("%Y-%m-%d %H:%M:%S") %></td>
38
+ <td class="so-trace__component">
39
+ <% component_tone = component_tones[row[:component]] || "default" %>
40
+ <span class="so-badge so-badge--pill so-badge--<%= component_tone %>">
41
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true"><circle r="3" cx="3" cy="3" /></svg>
42
+ <%= row[:component].to_s.capitalize %>
43
+ </span>
44
+ </td>
45
+ <td class="so-trace__event"><%= row[:event_type] %></td>
46
+ <td class="so-trace__details">
47
+ <% if row[:event_type] == "cable_summary" %>
48
+ <%= row[:collapsed_count] %> additional Cable broadcast events collapsed
49
+ <% else %>
50
+ <% case row[:component]
51
+ when :queue %>
52
+ <%= [
53
+ ("job=#{row[:job_class]}" if row[:job_class]),
54
+ ("queue=#{row[:queue_name]}" if row[:queue_name]),
55
+ ("duration=#{format_duration(row[:duration])}" if row[:duration])
56
+ ].compact.join(" ") %>
57
+ <% when :cache %>
58
+ <%= [
59
+ ("hit=#{row[:hit]}" unless row[:hit].nil?),
60
+ ("error=#{row[:error_class]}" if row[:error_class]),
61
+ ("duration=#{format_duration(row[:duration])}" if row[:duration])
62
+ ].compact.join(" ") %>
63
+ <% when :cable %>
64
+ <%= [
65
+ ("channel=#{row[:channel_class]}" if row[:channel_class]),
66
+ ("error=#{row[:error_class]}" if row[:error_class]),
67
+ ("duration=#{format_duration(row[:duration])}" if row[:duration]),
68
+ ("collapsed=#{row[:collapsed_count]}" if row[:collapsed_count])
69
+ ].compact.join(" ") %>
70
+ <% end %>
71
+ <% end %>
72
+ </td>
73
+ </tr>
74
+ <% end %>
75
+ </tbody>
76
+ </table>
77
+ <% else %>
78
+ <%= render "solid_observer/shared/empty_state", message: "No events found for correlation ID #{params[:id]}. The trace may have expired, not yet recorded, or belong to an unavailable component." %>
79
+ <% end %>
80
+ </div>
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
@@ -19,4 +21,5 @@ SolidObserver::Engine.routes.draw do
19
21
 
20
22
  resource :storage, only: %i[show]
21
23
  resources :events, only: %i[index show]
24
+ resources :traces, only: [:show]
22
25
  end
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCorrelationIdToCacheEvents < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :solid_observer_cache_events, :correlation_id, :string, limit: 64
6
+ add_index :solid_observer_cache_events, :correlation_id, where: "correlation_id IS NOT NULL"
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCorrelationIdToCableEvents < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :solid_observer_cable_events, :correlation_id, :string, limit: 64
6
+ add_index :solid_observer_cable_events, :correlation_id, where: "correlation_id IS NOT NULL"
7
+ end
8
+ 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"