solid_observer 0.5.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +25 -10
  4. data/app/controllers/solid_observer/dashboard_controller.rb +20 -6
  5. data/app/controllers/solid_observer/traces_controller.rb +17 -0
  6. data/app/helpers/solid_observer/application_helper.rb +31 -3
  7. data/app/views/layouts/solid_observer/application.html.erb +16 -1
  8. data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +6 -1
  9. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +6 -1
  10. data/app/views/solid_observer/dashboard/_component_cards.html.erb +18 -0
  11. data/app/views/solid_observer/dashboard/_health.html.erb +6 -0
  12. data/app/views/solid_observer/dashboard/_home.html.erb +10 -0
  13. data/app/views/solid_observer/dashboard/_queue.html.erb +138 -0
  14. data/app/views/solid_observer/dashboard/_unified_feed.html.erb +38 -0
  15. data/app/views/solid_observer/dashboard/index.html.erb +5 -138
  16. data/app/views/solid_observer/events/index.html.erb +6 -1
  17. data/app/views/solid_observer/traces/show.html.erb +80 -0
  18. data/config/routes.rb +1 -0
  19. data/db/migrate/20260630000001_add_correlation_id_to_cache_events.rb +8 -0
  20. data/db/migrate/20260630000002_add_correlation_id_to_cable_events.rb +8 -0
  21. data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -2
  22. data/lib/solid_observer/cli/health.rb +49 -0
  23. data/lib/solid_observer/cli/trace.rb +106 -0
  24. data/lib/solid_observer/configuration.rb +1 -4
  25. data/lib/solid_observer/correlated.rb +23 -0
  26. data/lib/solid_observer/queries/trace_query.rb +151 -0
  27. data/lib/solid_observer/services/health_score.rb +85 -0
  28. data/lib/solid_observer/services/record_cable_event.rb +1 -0
  29. data/lib/solid_observer/services/record_cache_event.rb +1 -0
  30. data/lib/solid_observer/services/unified_feed.rb +101 -0
  31. data/lib/solid_observer/version.rb +1 -1
  32. data/lib/solid_observer.rb +6 -0
  33. data/lib/tasks/solid_observer.rake +15 -0
  34. metadata +17 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 563c4874ef7e6a8ad8a485d0b3f86c9d1d8f924d5308d44c5137f76bda7e03de
4
- data.tar.gz: 88ba3331b45977349604047db1a4e3e98efb17ebc1eca8d4c52a96825d320a83
3
+ metadata.gz: 7c363ea187ff44b2a28184384cfbdee1d570c6e8fd981abceded3844819a2338
4
+ data.tar.gz: ff5bb32bb53ad65ea688365a5b8666aa34552580d3d78856f8e542fcf53b6d4c
5
5
  SHA512:
6
- metadata.gz: e56550b5f3cfbbb6a54325901a19bb4a70850a994e047d282cea653b420822cd1a9148c603c2886c92fb5bc9683b650587db575ef90d85b39800430ee7ffbdde
7
- data.tar.gz: dbf38ea6c5205ef027642cf02b84e3c59950299ea0477a2f7fef8df7543d71ece58dd90026f396650a99ad948ffa49cf16978f6a596c998d87ab085df739a114
6
+ metadata.gz: cbac07f62a04d4ea7ae4c8c2c1200f04c07f400f94ca5775785380853cd63f56eccdc154b1debe55438fc60482d54be55199265c299eae43577e1943b60abd1e
7
+ data.tar.gz: '018f433df069c72a8a12ced3434e4b05f5a26eb6209bb33af0e5c29b3aca2df1961359692e250f247769cb0e9bab322b946dc208ca265688fb4ae0316de24dd9'
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [0.6.0] - 2026-08-04
2
+
3
+ Headline: **Cross-component Tracing, Health Scoring & Unified Dashboard** — trace correlation across Queue/Cache/Cable, unified home dashboard, health scoring service, and CLI health & trace commands.
4
+
5
+ ### Added
6
+ - **Correlation ID propagation** — automatic correlation ID generation and propagation across ActiveJob (`Correlated.stamp`), SolidCache, and SolidCable events; configurable `correlation_id_generator`.
7
+ - **Cross-component trace service & CLI** — `Queries::TraceQuery` for fetching cross-component events by `correlation_id`; `rails solid_observer:trace[CORRELATION_ID,LIMIT]` CLI command.
8
+ - **Trace Web UI** — Web interface at `/solid_observer/traces/:correlation_id` displaying component-tagged event timelines across Queue, Cache, and Cable.
9
+ - **Unified health scoring service** — `Services::HealthScore` auto-calculates overall host stability using worst-of component statuses (Queue, Cache, Cable) with zero configuration required.
10
+ - **CLI health command** — `rails solid_observer:health` CLI command displaying aggregate and per-component health status.
11
+
12
+ ### Changed
13
+ - **Combined / Unified Dashboard** — Engine root (`GET /solid_observer/`) now renders the unified Home dashboard (overall health banner, per-component status cards, and merged multi-component activity feed). Dedicated Queue overview moved to `/solid_observer/queue`.
14
+
15
+ ### Upgrade & Database Migration
16
+ - **Required DB Migration** — Running `bin/rails solid_observer:install:migrations && bin/rails db:migrate` is required when upgrading to v0.6.0. Adds `correlation_id` columns to `solid_observer_cache_events` and `solid_observer_cable_events` tables.
17
+
1
18
  ## [0.5.0] - 2026-06-24
2
19
 
3
20
  Headline: **Solid Cable observability** — optional Cable telemetry, dashboard, storage health, and guarded trim controls.
data/README.md CHANGED
@@ -7,33 +7,35 @@
7
7
  </p>
8
8
 
9
9
  <p align="center">
10
- <a href="https://github.com/bart-oz/solid_observer/releases"><img src="https://img.shields.io/badge/version-0.5.0-blue.svg" alt="Version"></a>
10
+ <a href="https://github.com/bart-oz/solid_observer/releases"><img src="https://img.shields.io/badge/version-0.6.0-blue.svg" alt="Version"></a>
11
11
  <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
12
12
  <a href="https://github.com/bart-oz/solid_observer/actions"><img src="https://img.shields.io/badge/tests-passing-brightgreen.svg" alt="Tests"></a>
13
- <a href="https://github.com/bart-oz/solid_observer/actions"><img src="https://img.shields.io/badge/coverage-96.22%25-brightgreen.svg" alt="Coverage"></a>
14
- </p>
13
+ <a href="https://github.com/bart-oz/solid_observer/actions"><img src="https://img.shields.io/badge/coverage-97.96%25-brightgreen.svg" alt="Coverage"></a>
15
14
 
16
15
  ---
17
16
  <p align="center">
18
17
  <a href=".github/assets/dash_1.png"><img src=".github/assets/dash_1.png" alt="SolidObserver dashboard overview" width="700"></a>
19
18
  </p>
20
19
 
21
- SolidObserver is a production-grade observability solution for Rails 8's Solid Stack. v0.5.0 covers **Solid Queue**, **Solid Cache**, and **Solid Cable** with a unified Web UI dashboard, CLI tools, metrics collection, and distributed tracing support.
20
+ SolidObserver is a production-grade observability solution for Rails 8's Solid Stack. v0.6.0 covers **Solid Queue**, **Solid Cache**, and **Solid Cable** with a unified Web UI dashboard, health scoring (`Services::HealthScore`), CLI tools, metrics collection, and cross-component trace correlation by `correlation_id`.
22
21
 
23
- ## Features (v0.5.0)
22
+ ## Features (v0.6.0)
24
23
 
25
24
  | | Solid Queue | Solid Cache | Solid Cable |
26
25
  |---|---|---|---|
27
26
  | **Web UI Dashboard** | Queue stats, jobs browser, events log | Hit rate, ops/sec, error rate, avg duration | Broadcasts, rejection rate, trends |
27
+ | **Unified Home** | Overall health banner, component cards, merged activity feed at `/solid_observer` |
28
+ | **Health Scoring** | Worst-of aggregate stability (`Services::HealthScore`), config-free |
29
+ | **Cross-Component Trace** | Timeline by `correlation_id` across Queue, Cache, Cable (`/solid_observer/traces/:id`) |
28
30
  | **Storage footprint** | DB size, event counts | SolidCache table size, row counts | Message count + backlogs |
29
31
  | **Activity trends** | Sparklines (Performed, Ready, Failed) | Activity trend sparklines | Broadcast/rejection sparklines |
30
32
  | **Stability indicator** | Stable / Degraded / Critical badge | Stability pill badge | Stable / Degraded / Critical (hybrid) |
31
33
  | **Operational controls** | Retry / discard failed jobs | Prune expired entries, clear all entries | Trim expired messages |
32
- | **CLI tools** | status, jobs:list/show/retry/discard | cache:prune, cache:clear | cable:trim |
34
+ | **CLI tools** | status, health, trace, jobs:list/show/retry/discard | cache:prune, cache:clear | cable:trim |
33
35
  | **Privacy** | Job arguments excluded from persisted events | Keys and values **never** shown | Broadcasting names hashed (SHA256) |
34
36
  | **Operating modes** | Real-time (no DB) or persistence (full history) | Persistence mode only | Persistence mode only |
35
37
 
36
- Additional: 🔗 APM distributed tracing · ⚡ buffered writes · 🛡️ Docker/CI/K8s safe boot
38
+ Additional: 🔗 Cross-component trace lookup by correlation_id · 🩺 Config-free aggregate health score · 🏠 Unified Home dashboard · ⚡ buffered writes · 🛡️ Docker/CI/K8s safe boot
37
39
 
38
40
  ## Requirements
39
41
 
@@ -175,6 +177,14 @@ Configuration:
175
177
 
176
178
  SolidObserver ships with a zero-dependency Web UI (no asset pipeline, no JS framework) at `/solid_observer`.
177
179
 
180
+ ### Views & Routes
181
+
182
+ - **Unified Home Dashboard** (`/solid_observer/`) — Overall worst-of health score banner, per-component status cards (Queue, Cache, Cable), and a unified cross-component activity feed.
183
+ - **Queue Overview** (`/solid_observer/queue`) — Dedicated Queue metrics, job browser (ready/scheduled/claimed/failed), events log, time-range trends, and operational controls.
184
+ - **Cache Dashboard** (`/solid_observer/cache_dashboard`) — SolidCache hit rates, ops/sec, error rates, storage footprint, and prune/clear controls.
185
+ - **Cable Dashboard** (`/solid_observer/cable_dashboard`) — SolidCable broadcast volume, rejection rates, backlog trends, and message trim controls.
186
+ - **Cross-Component Trace Timeline** (`/solid_observer/traces/:correlation_id`) — Component-tagged event sequence across Queue, Cache, and Cable for a specific correlation ID.
187
+
178
188
  ### Mount
179
189
 
180
190
  The install generator mounts it for you. To mount manually:
@@ -398,7 +408,10 @@ SolidObserver.configure do |config|
398
408
  end
399
409
  ```
400
410
 
401
- When configured, all job events will include your correlation ID, allowing you to trace jobs back to the originating request.
411
+ When configured, all job, cache, and cable events will include your correlation ID. You can query the full cross-component event timeline for any request or job:
412
+
413
+ - **CLI**: `bin/rails "solid_observer:trace[CORRELATION_ID,LIMIT]"`
414
+ - **Web UI**: `/solid_observer/traces/:correlation_id`
402
415
 
403
416
  ### Production hardening (recommended)
404
417
 
@@ -427,6 +440,7 @@ end
427
440
  | Command | Description |
428
441
  |---------|-------------|
429
442
  | `solid_observer:status` | Show queue status overview |
443
+ | `solid_observer:health` | Display aggregate and per-component health status |
430
444
  | `solid_observer:jobs:list[status,queue,class,limit]` | List jobs with optional filters |
431
445
  | `solid_observer:jobs:show[ID]` | Show job details |
432
446
  | `solid_observer:jobs:retry[ID]` | Retry a failed job |
@@ -444,6 +458,7 @@ end
444
458
  | `solid_observer:cache:clear` | Clear all SolidCache entries (with confirmation) |
445
459
  | `solid_observer:cache:prune` | Prune expired SolidCache entries |
446
460
  | `solid_observer:cable:trim` | Trim expired Solid Cable messages |
461
+ | `solid_observer:trace[CORRELATION_ID,LIMIT]` | Show cross-component trace timeline |
447
462
 
448
463
  > **Note:** Storage commands manage **SolidObserver's storage** (event logs, metrics, snapshots) — not Solid Queue's jobs. Cache commands operate on SolidCache entries in the host app's cache store.
449
464
 
@@ -534,8 +549,8 @@ gem "sqlite3", "~> 2.0"
534
549
  | v0.1.1 | Real-time mode (no migrations needed) | ✅ Released |
535
550
  | v0.3.0 | Web UI dashboard + stability hardening | ✅ Released |
536
551
  | v0.4.0 | Solid Cache monitoring | ✅ Released |
537
- | v0.5.0 | Solid Cable monitoring | ✅ Current |
538
- | v0.6.0 | Cross-component correlation, health scores | 🔜 Planned |
552
+ | v0.5.0 | Solid Cable monitoring | ✅ Released |
553
+ | v0.6.0 | Cross-component trace correlation, health scores, unified dashboard | Current |
539
554
  | v0.7.0 | Alerting & notifications | 🔜 Planned |
540
555
  | v1.0.0 | Production stable release | 🎯 Goal |
541
556
 
@@ -12,10 +12,23 @@ module SolidObserver
12
12
  def index
13
13
  @component = selected_component
14
14
 
15
- return unless @component == "queue" && SolidObserver.config.solid_queue_enabled?
16
-
17
- assign_range_and_stats
18
- load_persistence_data if persistence_mode?
15
+ if @component == "queue" && SolidObserver.config.solid_queue_enabled?
16
+ assign_range_and_stats
17
+ load_persistence_data if persistence_mode?
18
+ elsif @component == "home"
19
+ @health = begin
20
+ Services::HealthScore.call
21
+ rescue => error
22
+ Rails.logger&.warn("Home HealthScore: #{error.class}: #{error.message}")
23
+ {overall: :degraded, components: {}}
24
+ end
25
+ @feed = begin
26
+ Services::UnifiedFeed.call
27
+ rescue => error
28
+ Rails.logger&.warn("Home UnifiedFeed: #{error.class}: #{error.message}")
29
+ []
30
+ end
31
+ end
19
32
  end
20
33
 
21
34
  def live_poll
@@ -98,17 +111,18 @@ module SolidObserver
98
111
  ""
99
112
  end
100
113
  requested = path_component if requested.empty?
114
+ return "queue" if requested == "queue" && SolidObserver.config.solid_queue_enabled?
101
115
  return "cache" if requested == "cache" && SolidObserver.config.solid_cache_enabled?
102
116
 
103
- "queue"
117
+ "home"
104
118
  end
105
119
 
106
120
  def path_component
107
121
  return "" unless request&.respond_to?(:path)
108
122
 
109
123
  path = request&.path.to_s
110
- return "cache" if path.end_with?("/cache")
111
124
  return "queue" if path.end_with?("/queue")
125
+ return "cache" if path.end_with?("/cache")
112
126
 
113
127
  ""
114
128
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../helpers/solid_observer/application_helper"
4
+
5
+ module SolidObserver
6
+ class TracesController < ApplicationController
7
+ helper SolidObserver::ApplicationHelper
8
+
9
+ include RequirePersistenceMode
10
+
11
+ def show
12
+ result = SolidObserver::Queries::TraceQuery.new.call(correlation_id: params[:id])
13
+ @rows = result.rows
14
+ @unavailable_components = result.unavailable_components
15
+ end
16
+ end
17
+ end
@@ -250,9 +250,37 @@ module SolidObserver
250
250
  SolidObserver.config.solid_cable_enabled?
251
251
  end
252
252
 
253
- def dashboard_section_active?(component)
254
- current_component = @component.presence || "queue"
255
- controller_name == "dashboard" && current_component == component.to_s
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
256
284
  end
257
285
 
258
286
  private
@@ -524,6 +524,19 @@
524
524
 
525
525
  .so-card li + li { margin-top: 0.45rem; }
526
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
+
527
540
  @media (max-width: 960px) {
528
541
  .so-dashboard-toolbar { flex-direction: column; align-items: stretch; }
529
542
  .so-dashboard-toolbar__left, .so-dashboard-toolbar__right { margin-left: 0; }
@@ -553,9 +566,11 @@
553
566
  <aside class="so-sidebar">
554
567
  <div class="so-sidebar__logo">SolidObserver</div>
555
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
+
556
571
  <% if SolidObserver.config.solid_queue_enabled? %>
557
572
  <div class="so-sidebar__section">Queue</div>
558
- <%= 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) %>
559
574
  <%= link_to "Jobs", jobs_path, class: ("active" if controller_name == "jobs"), "aria-current": (controller_name == "jobs" ? "page" : nil) %>
560
575
  <% if persistence_mode? %>
561
576
  <%= link_to "Events", events_path, class: ("active" if controller_name == "events"), "aria-current": (controller_name == "events" ? "page" : nil) %>
@@ -19,7 +19,12 @@
19
19
  <tbody>
20
20
  <% recent_events.each do |event| %>
21
21
  <tr>
22
- <td><%= event.event_type.to_s %></td>
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>
23
28
  <td><%= event.channel_class.presence || "—" %></td>
24
29
  <td><span class="so-cable-dashboard__digest"><%= cable_event_digest(event.broadcasting_digest) %></span></td>
25
30
  <td><%= event.duration ? format_duration(event.duration) : "—" %></td>
@@ -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>
@@ -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>
@@ -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>