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
@@ -156,6 +156,63 @@ module SolidObserver
156
156
  CACHE_RANGE_LABELS.fetch(range_key.to_s, "in selected range")
157
157
  end
158
158
 
159
+ def cable_range_label(range_key)
160
+ CACHE_RANGE_LABELS.fetch(range_key.to_s, "in selected range")
161
+ end
162
+
163
+ def cable_ratio_percent(value)
164
+ number_to_percentage(value.to_f * 100, precision: 1, strip_insignificant_zeros: true)
165
+ end
166
+
167
+ def cable_stability_badge(state)
168
+ stability_badge_for(state.to_sym)
169
+ end
170
+
171
+ def cable_stability_detail(stability)
172
+ state = (stability || {})[:state]&.to_sym
173
+ state = :stable unless STABILITY_STATES.key?(state)
174
+
175
+ case state
176
+ when :critical
177
+ critical_cable_stability_detail(stability)
178
+ when :degraded
179
+ degraded_cable_stability_detail(stability)
180
+ else
181
+ "No cable errors or subscription rejections in the selected range and backlog current snapshot is healthy"
182
+ end
183
+ end
184
+
185
+ def cable_event_digest(digest, visible_chars: 10)
186
+ digest = digest.to_s
187
+ return "—" if digest.empty?
188
+ return digest if digest.length <= visible_chars
189
+
190
+ "#{digest.first(visible_chars)}…"
191
+ end
192
+
193
+ # :reek:FeatureEnvy
194
+ def cable_backlog_summary(stats)
195
+ if stats[:backlog_available]
196
+ {
197
+ value: number_with_delimiter(stats[:backlog_count].to_i),
198
+ subtitle: "current Solid Cable snapshot"
199
+ }
200
+ else
201
+ {value: "—", subtitle: "current Solid Cable snapshot unavailable"}
202
+ end
203
+ end
204
+
205
+ def cable_storage_summary(storage_components)
206
+ snapshots = Array(storage_components)
207
+ reason = cable_storage_unavailable_reason(snapshots)
208
+ return {value: "—", subtitle: "— #{reason}"} if reason
209
+
210
+ {
211
+ value: number_to_human_size(cable_storage_total_bytes(snapshots), precision: 1, significant: false, strip_insignificant_zeros: false),
212
+ subtitle: "Cable telemetry + Solid Cable messages"
213
+ }
214
+ end
215
+
159
216
  def cache_stability_detail(stability)
160
217
  state = (stability || {})[:state]&.to_sym
161
218
  state = :stable unless STABILITY_STATES.key?(state)
@@ -189,9 +246,41 @@ module SolidObserver
189
246
  SolidObserver.config.solid_cache_enabled?
190
247
  end
191
248
 
192
- def dashboard_section_active?(component)
193
- current_component = @component.presence || "queue"
194
- controller_name == "dashboard" && current_component == component.to_s
249
+ def cable_component_enabled?
250
+ SolidObserver.config.solid_cable_enabled?
251
+ end
252
+
253
+ def home?
254
+ controller_name == "dashboard" && @component.to_s == "home"
255
+ end
256
+
257
+ def health_status_badge(status)
258
+ stability_badge_for(status.to_sym)
259
+ rescue KeyError
260
+ Rails.logger&.warn("health_status_badge: unknown status #{status.inspect}, falling back to degraded")
261
+ stability_badge_for(:degraded)
262
+ end
263
+
264
+ def unified_feed_detail(row)
265
+ detail = row[:detail].to_s
266
+ if row[:correlation_id].present?
267
+ link_to(detail.presence || row[:correlation_id], trace_path(row[:correlation_id]), class: "so-trace__link")
268
+ else
269
+ detail.presence || "—"
270
+ end
271
+ end
272
+
273
+ def component_overview_path(name)
274
+ case name.to_sym
275
+ when :queue
276
+ queue_dashboard_path
277
+ when :cache
278
+ cache_dashboard_path
279
+ when :cable
280
+ cable_dashboard_path
281
+ else
282
+ root_path
283
+ end
195
284
  end
196
285
 
197
286
  private
@@ -231,6 +320,59 @@ module SolidObserver
231
320
  snapshots.find { |snapshot| !snapshot[:available] }&.[](:unavailable_reason)
232
321
  end
233
322
 
323
+ # :reek:FeatureEnvy
324
+ # :reek:TooManyStatements
325
+ def critical_cable_stability_detail(stability)
326
+ parts = []
327
+ error_count = stability[:error_count].to_i
328
+ parts << pluralize(error_count, "cable error") if error_count.positive?
329
+
330
+ rejection_count = stability[:rejection_count].to_i
331
+ rejection_rate = stability[:rejection_rate].to_f
332
+ if rejection_rate > 0.0
333
+ parts << "#{cable_ratio_percent(rejection_rate)} rejection rate"
334
+ elsif rejection_count.positive?
335
+ parts << pluralize(rejection_count, "subscription rejection")
336
+ end
337
+
338
+ backlog_ratio = stability[:backlog_ratio].to_f
339
+ if backlog_ratio >= 0.5
340
+ parts << "backlog at #{number_to_percentage(backlog_ratio * 100, precision: 0)} in current snapshot"
341
+ end
342
+
343
+ return "Cable stability critical" if parts.empty?
344
+
345
+ "#{parts.join("; ")} in the selected range"
346
+ end
347
+
348
+ # :reek:FeatureEnvy
349
+ # :reek:TooManyStatements
350
+ def degraded_cable_stability_detail(stability)
351
+ return "Backlog current snapshot unavailable" unless stability[:backlog_available]
352
+
353
+ backlog_ratio = stability[:backlog_ratio].to_f
354
+ if backlog_ratio >= SolidObserver.config.cable_backlog_threshold.to_f
355
+ return "Backlog at #{number_to_percentage(backlog_ratio * 100, precision: 0)} in current snapshot"
356
+ end
357
+
358
+ rejection_count = stability[:rejection_count].to_i
359
+ if rejection_count.positive?
360
+ return "#{pluralize(rejection_count, "subscription rejection")} in the selected range"
361
+ end
362
+
363
+ "Cable stability degraded"
364
+ end
365
+
366
+ def cable_storage_total_bytes(snapshots)
367
+ snapshots.sum { |snapshot| snapshot[:db_size_bytes].to_i }
368
+ end
369
+
370
+ def cable_storage_unavailable_reason(snapshots)
371
+ return "Storage snapshot unavailable" unless snapshots.size == 2
372
+
373
+ snapshots.find { |snapshot| !snapshot[:available] }&.[](:unavailable_reason)
374
+ end
375
+
234
376
  def cache_event_outcome_meta(event)
235
377
  hit = event.hit
236
378
 
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class CableEvent < BaseEvent
5
+ self.table_name = "solid_observer_cable_events"
6
+
7
+ validates :event_type, presence: true
8
+ validates :recorded_at, presence: true
9
+
10
+ scope :errored, -> { where.not(error_class: nil) }
11
+ scope :recent, ->(limit = 10) { order(recorded_at: :desc).limit(limit) }
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class CableMetric < BaseRecord
5
+ self.table_name = "solid_observer_cable_metrics"
6
+
7
+ validates :period_start, presence: true
8
+ validates :broadcasts_count, :transmissions_count, :confirmations_count,
9
+ :rejections_count, :perform_actions_count, :errors_count,
10
+ numericality: {only_integer: true, greater_than_or_equal_to: 0}
11
+ end
12
+ end
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidObserver
4
- class CacheMetric < BaseMetric
4
+ class CacheMetric < BaseRecord
5
5
  self.table_name = "solid_observer_cache_metrics"
6
- clear_validators!
7
6
 
8
7
  validates :event_type, presence: true, length: {maximum: 64}
9
8
  validates :period_start, presence: true
@@ -6,7 +6,7 @@ module SolidObserver
6
6
 
7
7
  MB_TO_BYTES = 1_048_576
8
8
  GB_TO_BYTES = 1_073_741_824
9
- COMPONENTS = %w[queue_observer cache_observer solid_cache].freeze
9
+ COMPONENTS = %w[queue_observer cache_observer solid_cache cable_observer solid_cable].freeze
10
10
 
11
11
  validates :db_size_bytes, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
12
12
  validates :event_count, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
@@ -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
  }
@@ -518,6 +524,19 @@
518
524
 
519
525
  .so-card li + li { margin-top: 0.45rem; }
520
526
 
527
+ /* SO-098 trace page */
528
+ .so-trace { max-width: 1120px; }
529
+ .so-trace__meta { margin-top: .35rem; font-size: .875rem; line-height: 1.5; color: var(--so-text-subtle); }
530
+ .so-trace__correlation { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; overflow-wrap: anywhere; }
531
+ .so-trace__table { min-width: 760px; }
532
+ .so-trace__time { width: 10.75rem; white-space: nowrap; }
533
+ .so-trace__component { width: 7.75rem; white-space: nowrap; }
534
+ .so-trace__event { width: 10.5rem; white-space: nowrap; }
535
+ .so-trace__details { min-width: 20rem; white-space: normal; overflow-wrap: anywhere; line-height: 1.5; }
536
+ .so-trace__link { color: var(--so-info); text-decoration: none; }
537
+ .so-trace__link:hover { text-decoration: underline; }
538
+ .so-trace__link:focus-visible { outline: 2px solid var(--so-info); outline-offset: 2px; box-shadow: 0 0 0 3px var(--so-focus-ring); }
539
+
521
540
  @media (max-width: 960px) {
522
541
  .so-dashboard-toolbar { flex-direction: column; align-items: stretch; }
523
542
  .so-dashboard-toolbar__left, .so-dashboard-toolbar__right { margin-left: 0; }
@@ -547,9 +566,11 @@
547
566
  <aside class="so-sidebar">
548
567
  <div class="so-sidebar__logo">SolidObserver</div>
549
568
  <nav class="so-sidebar__nav" aria-label="SolidObserver navigation">
569
+ <%= link_to "Home", root_path, class: ("active" if home?), "aria-current": (home? ? "page" : nil) %>
570
+
550
571
  <% if SolidObserver.config.solid_queue_enabled? %>
551
572
  <div class="so-sidebar__section">Queue</div>
552
- <%= link_to "Overview", root_path, class: ("active" if controller_name == "dashboard" && @component.to_s != "cache"), "aria-current": (controller_name == "dashboard" && @component.to_s != "cache" ? "page" : nil) %>
573
+ <%= link_to "Overview", queue_dashboard_path, class: ("active" if controller_name == "dashboard" && @component.to_s == "queue"), "aria-current": (controller_name == "dashboard" && @component.to_s == "queue" ? "page" : nil) %>
553
574
  <%= link_to "Jobs", jobs_path, class: ("active" if controller_name == "jobs"), "aria-current": (controller_name == "jobs" ? "page" : nil) %>
554
575
  <% if persistence_mode? %>
555
576
  <%= link_to "Events", events_path, class: ("active" if controller_name == "events"), "aria-current": (controller_name == "events" ? "page" : nil) %>
@@ -564,7 +585,12 @@
564
585
  <% end %>
565
586
  <% end %>
566
587
 
567
- <% if persistence_mode? && (SolidObserver.config.solid_queue_enabled? || SolidObserver.config.solid_cache_enabled?) %>
588
+ <% if SolidObserver.config.solid_cable_enabled? %>
589
+ <div class="so-sidebar__section">Cable</div>
590
+ <%= link_to "Overview", cable_dashboard_path, class: ("active" if controller_name == "cable_dashboard"), "aria-current": (controller_name == "cable_dashboard" ? "page" : nil) %>
591
+ <% end %>
592
+
593
+ <% if persistence_mode? && (SolidObserver.config.solid_queue_enabled? || SolidObserver.config.solid_cache_enabled? || SolidObserver.config.solid_cable_enabled?) %>
568
594
  <%= link_to "Storage", storage_path, class: ("active" if controller_name == "storages"), "aria-current": (controller_name == "storages" ? "page" : nil) %>
569
595
  <% end %>
570
596
  </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,39 @@
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>
23
+ <%= event.event_type.to_s %>
24
+ <% if event.correlation_id.present? %>
25
+ <%= link_to "Trace →", trace_path(event.correlation_id), class: "so-trace__link" %>
26
+ <% end %>
27
+ </td>
28
+ <td><%= event.channel_class.presence || "—" %></td>
29
+ <td><span class="so-cable-dashboard__digest"><%= cable_event_digest(event.broadcasting_digest) %></span></td>
30
+ <td><%= event.duration ? format_duration(event.duration) : "—" %></td>
31
+ <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>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ <% else %>
37
+ <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>
38
+ <% end %>
39
+ </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>
@@ -19,7 +19,12 @@
19
19
  <tbody>
20
20
  <% recent_events.each do |event| %>
21
21
  <tr>
22
- <td><%= event.event_type.to_s.humanize %></td>
22
+ <td>
23
+ <%= event.event_type.to_s.humanize %>
24
+ <% if event.correlation_id.present? %>
25
+ <%= link_to "Trace →", trace_path(event.correlation_id), class: "so-trace__link" %>
26
+ <% end %>
27
+ </td>
23
28
  <td><span class="so-cache-dashboard__digest"><%= cache_event_digest(event.key_digest) %></span></td>
24
29
  <td><%= cache_event_outcome_badge(event) %></td>
25
30
  <td><%= event.duration ? format_duration(event.duration) : "—" %></td>
@@ -0,0 +1,18 @@
1
+ <% if health[:components].present? %>
2
+ <section class="so-cards-section" data-so-zone="home-cards" aria-labelledby="so-home-components-heading">
3
+ <h2 class="so-cards-section__title" id="so-home-components-heading">Components</h2>
4
+ <div class="so-stat-cards">
5
+ <% health[:components].each do |name, info| %>
6
+ <div class="so-card" aria-labelledby="so-card-<%= name %>">
7
+ <div class="so-card__label" id="so-card-<%= name %>"><%= name.to_s.titleize %></div>
8
+ <div class="so-card__value">
9
+ <%= health_status_badge(info[:status]) %>
10
+ </div>
11
+ <div class="so-card__subtitle">
12
+ <% unless info[:available] %><span class="so-badge so-badge--default">Unavailable</span> · <% end %><%= link_to "View overview →", component_overview_path(name), class: "so-stability__link" %>
13
+ </div>
14
+ </div>
15
+ <% end %>
16
+ </div>
17
+ </section>
18
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <% non_stable = health[:components].count { |_n, i| i[:status].to_sym != :stable } %>
2
+ <section class="so-stability" data-so-zone="home-health" aria-labelledby="so-health-heading">
3
+ <span class="so-stability__label" id="so-health-heading">Overall Health</span>
4
+ <%= health_status_badge(health[:overall]) %>
5
+ <span class="so-stability__detail"><%= non_stable.zero? ? "All included components stable" : "#{non_stable} component#{'s' unless non_stable == 1} not stable" %></span>
6
+ </section>
@@ -0,0 +1,10 @@
1
+ <% content_for :title, "Home" %>
2
+ <div class="so-dashboard" data-so-zone="home">
3
+ <div class="so-content__header">
4
+ <h1>Home</h1>
5
+ </div>
6
+
7
+ <%= render "health", health: health %>
8
+ <%= render "component_cards", health: health %>
9
+ <%= render "unified_feed", feed: feed %>
10
+ </div>