solid_observer 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/README.md +157 -28
- data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
- data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
- data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
- data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
- data/app/controllers/solid_observer/application_controller.rb +69 -0
- data/app/controllers/solid_observer/dashboard_controller.rb +79 -0
- data/app/controllers/solid_observer/events_controller.rb +50 -0
- data/app/controllers/solid_observer/jobs_controller.rb +85 -0
- data/app/controllers/solid_observer/storages_controller.rb +12 -0
- data/app/helpers/solid_observer/application_helper.rb +95 -0
- data/app/helpers/solid_observer/dashboard_helper.rb +39 -0
- data/app/models/solid_observer/queue_event.rb +134 -0
- data/app/models/solid_observer/queue_metric.rb +1 -1
- data/app/presenters/solid_observer/execution_presenter.rb +50 -0
- data/app/views/layouts/solid_observer/application.html.erb +470 -0
- data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
- data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
- data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
- data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
- data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
- data/app/views/solid_observer/dashboard/index.html.erb +113 -0
- data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
- data/app/views/solid_observer/events/index.html.erb +53 -0
- data/app/views/solid_observer/events/show.html.erb +47 -0
- data/app/views/solid_observer/jobs/index.html.erb +61 -0
- data/app/views/solid_observer/jobs/show.html.erb +71 -0
- data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
- data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
- data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
- data/app/views/solid_observer/storages/show.html.erb +39 -0
- data/bin/quality_gate +95 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
- data/lib/generators/solid_observer/install_generator.rb +12 -25
- data/lib/generators/solid_observer/templates/initializer.rb.tt +5 -6
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/chart_buffer.rb +83 -0
- data/lib/solid_observer/cli/base.rb +2 -2
- data/lib/solid_observer/cli/jobs.rb +2 -2
- data/lib/solid_observer/cli/status.rb +20 -2
- data/lib/solid_observer/cli/storage.rb +41 -40
- data/lib/solid_observer/configuration.rb +47 -37
- data/lib/solid_observer/correlation_id_resolver.rb +8 -6
- data/lib/solid_observer/engine.rb +72 -17
- data/lib/solid_observer/params/events_filter.rb +37 -0
- data/lib/solid_observer/params/jobs_filter.rb +35 -0
- data/lib/solid_observer/queries/events_query.rb +27 -0
- data/lib/solid_observer/queries/execution_finder.rb +42 -0
- data/lib/solid_observer/queries/job_executions_query.rb +73 -0
- data/lib/solid_observer/queue_event_buffer.rb +163 -25
- data/lib/solid_observer/queue_stats.rb +165 -19
- data/lib/solid_observer/services/cleanup_storage.rb +58 -42
- data/lib/solid_observer/services/database_size.rb +86 -0
- data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
- data/lib/solid_observer/services/install_migrations.rb +49 -0
- data/lib/solid_observer/services/record_event.rb +51 -14
- data/lib/solid_observer/services/ui_auth_check.rb +65 -0
- data/lib/solid_observer/subscriber.rb +15 -8
- data/lib/solid_observer/version.rb +1 -1
- data/lib/solid_observer.rb +7 -0
- data/lib/tasks/solid_observer.rake +10 -2
- metadata +55 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<% content_for :title, "Dashboard" %>
|
|
2
|
+
|
|
3
|
+
<div class="so-dashboard">
|
|
4
|
+
<%# Zone A: Toolbar — range selector, Refresh, Live toggle, help disclosure %>
|
|
5
|
+
<div class="so-dashboard-toolbar" data-so-live>
|
|
6
|
+
<div class="so-dashboard-toolbar__left">
|
|
7
|
+
<label for="so-range" class="so-card__label so-filters__label">Range</label>
|
|
8
|
+
<select id="so-range" name="range" data-so-range-select>
|
|
9
|
+
<% SolidObserver::QueueStats::RANGES.each_key do |key| %>
|
|
10
|
+
<option value="<%= key %>" <%= "selected" if key == @range %>><%= key %></option>
|
|
11
|
+
<% end %>
|
|
12
|
+
</select>
|
|
13
|
+
<button type="button" class="so-btn so-btn--refresh" data-so-refresh aria-busy="false">Refresh data</button>
|
|
14
|
+
<span class="so-toolbar-freshness" data-so-freshness aria-live="polite"></span>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="so-dashboard-toolbar__right">
|
|
17
|
+
<label class="so-toggle so-toggle--pill <%= "so-toggle--on" if @live %>">
|
|
18
|
+
<input type="checkbox" name="live" value="on" <%= "checked" if @live %> data-so-live-toggle>
|
|
19
|
+
<span class="so-toggle__track" aria-hidden="true">
|
|
20
|
+
<span class="so-toggle__thumb"></span>
|
|
21
|
+
</span>
|
|
22
|
+
<span class="so-toggle__label">Live</span>
|
|
23
|
+
<span class="so-toggle__sep" aria-hidden="true"> · </span>
|
|
24
|
+
<span class="so-toggle__cadence" aria-live="polite"><%= @live ? "5s" : "off" %></span>
|
|
25
|
+
<span class="so-toggle__dot" aria-hidden="true"></span>
|
|
26
|
+
</label>
|
|
27
|
+
<div class="so-help-wrapper" data-so-help-wrapper>
|
|
28
|
+
<button type="button" class="so-btn so-btn--help" data-so-help-btn aria-expanded="false" aria-controls="so-help-panel">(?)
|
|
29
|
+
</button>
|
|
30
|
+
<div id="so-help-panel" class="so-help-panel" data-so-help-panel hidden>
|
|
31
|
+
<p>Live mode polls every 5 seconds for current queue state. Throughput and chart data update only on range change or manual refresh.</p>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<%# Zone B: Right now — live state %>
|
|
38
|
+
<section class="so-dashboard-section" aria-labelledby="so-live-state-heading">
|
|
39
|
+
<div class="so-dashboard-section__header">
|
|
40
|
+
<h2 class="so-dashboard-section__title" id="so-live-state-heading">Right now</h2>
|
|
41
|
+
<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>
|
|
42
|
+
</div>
|
|
43
|
+
<%= render "live_state", stats: @stats %>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<%# Zone C: Throughput in selected range %>
|
|
47
|
+
<% if persistence_mode? && @stats[:available] %>
|
|
48
|
+
<section class="so-dashboard-section" aria-labelledby="so-throughput-heading">
|
|
49
|
+
<div class="so-dashboard-section__header">
|
|
50
|
+
<h2 class="so-dashboard-section__title" id="so-throughput-heading">Throughput in selected range</h2>
|
|
51
|
+
<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>
|
|
52
|
+
</div>
|
|
53
|
+
<%= render "throughput", stats: @stats, range: @range %>
|
|
54
|
+
</section>
|
|
55
|
+
<% end %>
|
|
56
|
+
|
|
57
|
+
<%# Zone D: Charts %>
|
|
58
|
+
<% if @stats[:available] %>
|
|
59
|
+
<section class="so-dashboard-section" aria-labelledby="so-chart-heading">
|
|
60
|
+
<div class="so-dashboard-section__header">
|
|
61
|
+
<h2 class="so-dashboard-section__title" id="so-chart-heading">Charts</h2>
|
|
62
|
+
</div>
|
|
63
|
+
<%= render "chart", range: @range %>
|
|
64
|
+
</section>
|
|
65
|
+
<% end %>
|
|
66
|
+
|
|
67
|
+
<%# Zone E: Per-queue table %>
|
|
68
|
+
<%= render "queue_table", stats: @stats, range: @range %>
|
|
69
|
+
|
|
70
|
+
<%# Zone F: Stability strip %>
|
|
71
|
+
<% if @stats[:available] && persistence_mode? %>
|
|
72
|
+
<section class="so-stability" data-so-zone="stability" aria-labelledby="so-stability-heading">
|
|
73
|
+
<span class="so-stability__label" id="so-stability-heading">Stability</span>
|
|
74
|
+
<%= stability_badge(@stats) %>
|
|
75
|
+
<span class="so-stability__detail"><%= stability_detail(@stats) %></span>
|
|
76
|
+
<%= link_to "View failures →", events_path(event_type: "job_failed"), class: "so-stability__link" %>
|
|
77
|
+
</section>
|
|
78
|
+
<% end %>
|
|
79
|
+
|
|
80
|
+
<%# Zone G: Recent Events %>
|
|
81
|
+
<% if @stats[:available] && persistence_mode? %>
|
|
82
|
+
<% if defined?(@recent_events) && @recent_events.present? %>
|
|
83
|
+
<div class="so-card">
|
|
84
|
+
<div class="so-card__label">Recent Events</div>
|
|
85
|
+
<table class="so-table so-table--card">
|
|
86
|
+
<thead>
|
|
87
|
+
<tr>
|
|
88
|
+
<th>Event</th>
|
|
89
|
+
<th>Job Class</th>
|
|
90
|
+
<th>Queue</th>
|
|
91
|
+
<th>Time</th>
|
|
92
|
+
</tr>
|
|
93
|
+
</thead>
|
|
94
|
+
<tbody>
|
|
95
|
+
<% @recent_events.each do |event| %>
|
|
96
|
+
<tr>
|
|
97
|
+
<td><%= event.event_type.humanize %></td>
|
|
98
|
+
<td><%= event.job_class %></td>
|
|
99
|
+
<td><%= event.queue_name %></td>
|
|
100
|
+
<td><%= time_ago_in_words(event.recorded_at) %> ago</td>
|
|
101
|
+
</tr>
|
|
102
|
+
<% end %>
|
|
103
|
+
</tbody>
|
|
104
|
+
</table>
|
|
105
|
+
</div>
|
|
106
|
+
<% else %>
|
|
107
|
+
<div class="so-card">
|
|
108
|
+
<div class="so-card__label">Recent Events</div>
|
|
109
|
+
<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>
|
|
110
|
+
</div>
|
|
111
|
+
<% end %>
|
|
112
|
+
<% end %>
|
|
113
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<% content_for :title, "Storage Unavailable" %>
|
|
2
|
+
|
|
3
|
+
<div class="so-content__header">
|
|
4
|
+
<h1>SolidObserver storage is not reachable</h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="so-card so-card--spaced so-card--accent-danger">
|
|
8
|
+
<p class="so-card__label so-card__label--danger">
|
|
9
|
+
<strong><%= @error_class %></strong>
|
|
10
|
+
</p>
|
|
11
|
+
<pre class="so-pre so-pre--error"><%= @error_message %></pre>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="so-card so-card--spaced">
|
|
15
|
+
<h2>What to do next</h2>
|
|
16
|
+
<ol>
|
|
17
|
+
<li>Confirm the <code>solid_observer_queue</code> database in <code>config/database.yml</code> is reachable.</li>
|
|
18
|
+
<li>
|
|
19
|
+
Run migrations if you have not yet:
|
|
20
|
+
<pre class="so-pre">bin/rails solid_observer:install:migrations
|
|
21
|
+
bin/rails db:create
|
|
22
|
+
bin/rails db:migrate</pre>
|
|
23
|
+
</li>
|
|
24
|
+
<li>If the database server is temporarily down, reload this page once it recovers.</li>
|
|
25
|
+
<li>Switch to <code>storage_mode: :realtime</code> in <code>config/initializers/solid_observer.rb</code> if you want SolidObserver without a persistence database.</li>
|
|
26
|
+
</ol>
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<% content_for :title, "Events" %>
|
|
2
|
+
|
|
3
|
+
<div class="so-content__header">
|
|
4
|
+
<h1>Events</h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<%= form_tag events_path, method: :get, class: "so-filters" do %>
|
|
8
|
+
<%= select_tag :event_type,
|
|
9
|
+
options_for_select([["All Event Types", ""]] + @available_event_types.map { |t| [t.humanize, t] }, @event_type),
|
|
10
|
+
onchange: "this.form.submit()" %>
|
|
11
|
+
|
|
12
|
+
<%= select_tag :job_class,
|
|
13
|
+
options_for_select([["All Classes", ""]] + @available_job_classes.map { |c| [c, c] }, @job_class),
|
|
14
|
+
onchange: "this.form.submit()" %>
|
|
15
|
+
|
|
16
|
+
<%= select_tag :queue_name,
|
|
17
|
+
options_for_select([["All Queues", ""]] + @available_queues.map { |q| [q, q] }, @queue_name),
|
|
18
|
+
onchange: "this.form.submit()" %>
|
|
19
|
+
|
|
20
|
+
<%= date_field_tag :from, @from&.strftime("%Y-%m-%d"), placeholder: "From date", onchange: "this.form.submit()" %>
|
|
21
|
+
<%= date_field_tag :to, @to&.strftime("%Y-%m-%d"), placeholder: "To date", onchange: "this.form.submit()" %>
|
|
22
|
+
|
|
23
|
+
<%= link_to "Clear Filters", events_path, class: "so-btn" if @event_type.present? || @job_class.present? || @queue_name.present? || @from || @to %>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<% if @events.any? %>
|
|
27
|
+
<table class="so-table so-table--listing">
|
|
28
|
+
<thead>
|
|
29
|
+
<tr>
|
|
30
|
+
<th>Event</th>
|
|
31
|
+
<th>Job Class</th>
|
|
32
|
+
<th>Queue</th>
|
|
33
|
+
<th>Duration</th>
|
|
34
|
+
<th>Time</th>
|
|
35
|
+
</tr>
|
|
36
|
+
</thead>
|
|
37
|
+
<tbody>
|
|
38
|
+
<% @events.each do |event| %>
|
|
39
|
+
<tr>
|
|
40
|
+
<td><%= link_to event.event_type.humanize, event_path(event.id) %></td>
|
|
41
|
+
<td><%= event.job_class %></td>
|
|
42
|
+
<td><%= event.queue_name %></td>
|
|
43
|
+
<td><%= duration_with_semantic(event.duration, event.event_type) %></td>
|
|
44
|
+
<td><%= time_ago_in_words(event.recorded_at) %> ago</td>
|
|
45
|
+
</tr>
|
|
46
|
+
<% end %>
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
|
|
50
|
+
<%= render "solid_observer/shared/pagination", page: @page, total_pages: @total_pages %>
|
|
51
|
+
<% else %>
|
|
52
|
+
<%= render "solid_observer/shared/empty_state", message: "No events found matching the criteria" %>
|
|
53
|
+
<% end %>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<% content_for :title, "Event ##{@event.id}" %>
|
|
2
|
+
|
|
3
|
+
<div class="so-content__header">
|
|
4
|
+
<h1>Event #<%= @event.id %></h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="so-back-link">
|
|
8
|
+
<%= link_to "← Back to Events", events_path(event_type: params[:event_type], job_class: params[:job_class], queue_name: params[:queue_name], from: params[:from], to: params[:to]), class: "so-btn" %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="so-card so-card--spaced">
|
|
12
|
+
<table class="so-table so-details">
|
|
13
|
+
<tbody>
|
|
14
|
+
<tr>
|
|
15
|
+
<td>Event Type</td>
|
|
16
|
+
<td><%= @event.event_type.humanize %></td>
|
|
17
|
+
</tr>
|
|
18
|
+
<tr>
|
|
19
|
+
<td>Job Class</td>
|
|
20
|
+
<td><code><%= @event.job_class %></code></td>
|
|
21
|
+
</tr>
|
|
22
|
+
<tr>
|
|
23
|
+
<td>Queue</td>
|
|
24
|
+
<td><%= @event.queue_name %></td>
|
|
25
|
+
</tr>
|
|
26
|
+
<tr>
|
|
27
|
+
<td>Correlation ID</td>
|
|
28
|
+
<td><code><%= @event.correlation_id || "N/A" %></code></td>
|
|
29
|
+
</tr>
|
|
30
|
+
<tr>
|
|
31
|
+
<td>Duration</td>
|
|
32
|
+
<td><%= duration_with_semantic(@event.duration, @event.event_type) %></td>
|
|
33
|
+
</tr>
|
|
34
|
+
<tr>
|
|
35
|
+
<td>Recorded At</td>
|
|
36
|
+
<td><%= @event.recorded_at.strftime("%Y-%m-%d %H:%M:%S.%3N UTC") %></td>
|
|
37
|
+
</tr>
|
|
38
|
+
</tbody>
|
|
39
|
+
</table>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<% if @metadata %>
|
|
43
|
+
<div class="so-card so-card--spaced">
|
|
44
|
+
<div class="so-card__label">Metadata</div>
|
|
45
|
+
<pre class="so-pre"><code><%= JSON.pretty_generate(@metadata) %></code></pre>
|
|
46
|
+
</div>
|
|
47
|
+
<% end %>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<% content_for :title, "Jobs" %>
|
|
2
|
+
|
|
3
|
+
<div class="so-content__header">
|
|
4
|
+
<h1>Jobs</h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<%= form_tag jobs_path, method: :get, class: "so-filters" do %>
|
|
8
|
+
<%= select_tag :status,
|
|
9
|
+
options_for_select([["All Active", "all_active"], ["Ready", "ready"], ["Scheduled", "scheduled"], ["Claimed", "claimed"], ["Failed", "failed"]], @status),
|
|
10
|
+
onchange: "this.form.submit()" %>
|
|
11
|
+
|
|
12
|
+
<%= select_tag :queue_name,
|
|
13
|
+
options_for_select([["All Queues", ""]] + @available_queues.map { |q| [q, q] }, @queue_name),
|
|
14
|
+
onchange: "this.form.submit()" %>
|
|
15
|
+
|
|
16
|
+
<%= select_tag :job_class,
|
|
17
|
+
options_for_select([["All Classes", ""]] + @available_job_classes.map { |c| [c, c] }, @job_class),
|
|
18
|
+
onchange: "this.form.submit()" %>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<% if @jobs.any? %>
|
|
22
|
+
<table class="so-table so-table--listing">
|
|
23
|
+
<thead>
|
|
24
|
+
<tr>
|
|
25
|
+
<th>ID</th>
|
|
26
|
+
<th>Class</th>
|
|
27
|
+
<th>Queue</th>
|
|
28
|
+
<th>Status</th>
|
|
29
|
+
<th>Created</th>
|
|
30
|
+
<th>Actions</th>
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
<% @jobs.each do |execution| %>
|
|
35
|
+
<% job = execution.job %>
|
|
36
|
+
<% status = execution_status(execution) %>
|
|
37
|
+
<tr>
|
|
38
|
+
<td><%= link_to execution.id, job_path(execution.id, status: status) %></td>
|
|
39
|
+
<td><%= job&.class_name || "N/A" %></td>
|
|
40
|
+
<td><%= job&.queue_name || execution&.queue_name || "N/A" %></td>
|
|
41
|
+
<td><%= status_badge(status) %></td>
|
|
42
|
+
<td><%= time_ago_in_words(execution.created_at) %> ago</td>
|
|
43
|
+
<td>
|
|
44
|
+
<%= link_to "View", job_path(execution.id, status: status), class: "so-btn" %>
|
|
45
|
+
<% if execution.is_a?(SolidQueue::FailedExecution) %>
|
|
46
|
+
<%= button_to "Retry", retry_job_path(execution.id), method: :post, class: "so-btn so-btn--primary", form: { class: "so-form--inline" }, data: { confirm: "Retry job #{execution.id}?" } %>
|
|
47
|
+
<%= button_to "Discard", discard_job_path(execution.id), method: :post, class: "so-btn so-btn--danger", form: { class: "so-form--inline" }, data: { confirm: "Discard job #{execution.id}? This cannot be undone." } %>
|
|
48
|
+
<% end %>
|
|
49
|
+
</td>
|
|
50
|
+
</tr>
|
|
51
|
+
<% end %>
|
|
52
|
+
</tbody>
|
|
53
|
+
</table>
|
|
54
|
+
|
|
55
|
+
<%= render "solid_observer/shared/pagination", page: @page, total_pages: @total_pages %>
|
|
56
|
+
<% else %>
|
|
57
|
+
<%= render "solid_observer/shared/empty_state",
|
|
58
|
+
message: persistence_mode? ?
|
|
59
|
+
"No jobs match this filter. Successfully completed jobs are not stored here — see #{link_to("Events tab", events_path)} for the full job history.".html_safe :
|
|
60
|
+
"No jobs match this filter." %>
|
|
61
|
+
<% end %>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<% content_for :title, "Job ##{@execution.id}" %>
|
|
2
|
+
|
|
3
|
+
<div class="so-content__header">
|
|
4
|
+
<h1>Job #<%= @execution.id %></h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="so-back-link">
|
|
8
|
+
<%= link_to "← Back to Jobs", jobs_path(status: @status), class: "so-btn" %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="so-card so-card--spaced">
|
|
12
|
+
<table class="so-table so-details">
|
|
13
|
+
<tbody>
|
|
14
|
+
<tr>
|
|
15
|
+
<td>Job Class</td>
|
|
16
|
+
<td><code><%= @job&.class_name || "N/A" %></code></td>
|
|
17
|
+
</tr>
|
|
18
|
+
<tr>
|
|
19
|
+
<td>Job ID</td>
|
|
20
|
+
<td><%= @job&.id || "N/A" %></td>
|
|
21
|
+
</tr>
|
|
22
|
+
<tr>
|
|
23
|
+
<td>Status</td>
|
|
24
|
+
<td><%= status_badge(@status) %></td>
|
|
25
|
+
</tr>
|
|
26
|
+
<tr>
|
|
27
|
+
<td>Queue</td>
|
|
28
|
+
<td><%= @presenter.queue_name || "N/A" %></td>
|
|
29
|
+
</tr>
|
|
30
|
+
<tr>
|
|
31
|
+
<td>Priority</td>
|
|
32
|
+
<td><%= @presenter.priority || "N/A" %></td>
|
|
33
|
+
</tr>
|
|
34
|
+
<tr>
|
|
35
|
+
<td>Created At</td>
|
|
36
|
+
<td><%= @execution.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></td>
|
|
37
|
+
</tr>
|
|
38
|
+
<% if @execution.respond_to?(:scheduled_at) && @execution.scheduled_at %>
|
|
39
|
+
<tr>
|
|
40
|
+
<td>Scheduled At</td>
|
|
41
|
+
<td><%= @execution.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></td>
|
|
42
|
+
</tr>
|
|
43
|
+
<% end %>
|
|
44
|
+
</tbody>
|
|
45
|
+
</table>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<% if @execution.is_a?(SolidQueue::FailedExecution) && @execution.error %>
|
|
49
|
+
<div class="so-card so-card--accent-danger">
|
|
50
|
+
<div class="so-card__label so-card__label--danger">Error Details</div>
|
|
51
|
+
<table class="so-table so-details so-table--card">
|
|
52
|
+
<tbody>
|
|
53
|
+
<tr>
|
|
54
|
+
<td>Error Class</td>
|
|
55
|
+
<td><code><%= @execution.error.exception_class %></code></td>
|
|
56
|
+
</tr>
|
|
57
|
+
<tr>
|
|
58
|
+
<td>Message</td>
|
|
59
|
+
<td><%= @execution.error.message %></td>
|
|
60
|
+
</tr>
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
<div class="so-card__label">Backtrace</div>
|
|
64
|
+
<pre class="so-pre so-pre--error"><code><%= @execution.error.backtrace&.first(20)&.join("\n") || "No backtrace available" %></code></pre>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="so-actions">
|
|
68
|
+
<%= button_to "Retry Job", retry_job_path(@execution.id), method: :post, class: "so-btn so-btn--primary", data: { confirm: "Retry job #{@execution.id}?" } %>
|
|
69
|
+
<%= button_to "Discard Job", discard_job_path(@execution.id), method: :post, class: "so-btn so-btn--danger", data: { confirm: "Discard job #{@execution.id}? This cannot be undone." } %>
|
|
70
|
+
</div>
|
|
71
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<% if total_pages > 1 %>
|
|
2
|
+
<div class="so-pagination">
|
|
3
|
+
<% if page > 1 %>
|
|
4
|
+
<%= link_to "Previous", url_for(request.query_parameters.merge(page: page - 1)), class: "so-btn" %>
|
|
5
|
+
<% else %>
|
|
6
|
+
<span class="so-btn so-btn--disabled" aria-disabled="true">Previous</span>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<span class="so-btn so-btn--static so-pagination__status">Page <%= page %> of <%= total_pages %></span>
|
|
10
|
+
|
|
11
|
+
<% if page < total_pages %>
|
|
12
|
+
<%= link_to "Next", url_for(request.query_parameters.merge(page: page + 1)), class: "so-btn" %>
|
|
13
|
+
<% else %>
|
|
14
|
+
<span class="so-btn so-btn--disabled" aria-disabled="true">Next</span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% subtitle = local_assigns[:subtitle] %>
|
|
2
|
+
<% data_key = local_assigns[:data_key] %>
|
|
3
|
+
<div class="so-card">
|
|
4
|
+
<div class="so-card__label"><%= label %></div>
|
|
5
|
+
<% if subtitle.present? %>
|
|
6
|
+
<div class="so-card__subtitle"><%= subtitle %></div>
|
|
7
|
+
<% end %>
|
|
8
|
+
<div class="so-card__value"<%= data_key ? " data-so-card-value=\"#{data_key}\"".html_safe : "".html_safe %>><%= value || 0 %></div>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<% content_for :title, "Storage" %>
|
|
2
|
+
|
|
3
|
+
<div class="so-content__header">
|
|
4
|
+
<h1>Storage</h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<% if @current_storage %>
|
|
8
|
+
<div class="so-stat-cards">
|
|
9
|
+
<%= render "solid_observer/shared/stat_card", label: "Database Size", value: number_to_human_size(@current_storage.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %>
|
|
10
|
+
<%= render "solid_observer/shared/stat_card", label: "Event Count", value: number_with_delimiter(@current_storage.event_count) %>
|
|
11
|
+
<%= render "solid_observer/shared/stat_card", label: "Last Updated", value: time_ago_in_words(@current_storage.recorded_at) + " ago" %>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<% if @storage_history.any? %>
|
|
15
|
+
<div class="so-card so-card--section">
|
|
16
|
+
<div class="so-card__label">Recent Snapshots</div>
|
|
17
|
+
<table class="so-table so-table--card">
|
|
18
|
+
<thead>
|
|
19
|
+
<tr>
|
|
20
|
+
<th>Time</th>
|
|
21
|
+
<th>Size</th>
|
|
22
|
+
<th>Events</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
<% @storage_history.each do |snapshot| %>
|
|
27
|
+
<tr>
|
|
28
|
+
<td><%= snapshot.recorded_at.strftime("%Y-%m-%d %H:%M") %></td>
|
|
29
|
+
<td><%= number_to_human_size(snapshot.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %></td>
|
|
30
|
+
<td><%= number_with_delimiter(snapshot.event_count) %></td>
|
|
31
|
+
</tr>
|
|
32
|
+
<% end %>
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% else %>
|
|
38
|
+
<%= render "solid_observer/shared/empty_state", message: "No storage information available" %>
|
|
39
|
+
<% end %>
|
data/bin/quality_gate
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# SolidObserver Quality Gate Suite
|
|
5
|
+
# Runs all non-negotiable gates in sequence.
|
|
6
|
+
# Usage: bin/quality_gate [--skip-appraisal]
|
|
7
|
+
#
|
|
8
|
+
# Individual gates can be run via:
|
|
9
|
+
# bin/quality_gate rspec
|
|
10
|
+
# bin/quality_gate standardrb
|
|
11
|
+
# bin/quality_gate reek
|
|
12
|
+
# bin/quality_gate appraisal
|
|
13
|
+
# bin/quality_gate audit
|
|
14
|
+
|
|
15
|
+
SKIP_APPRAISAL=false
|
|
16
|
+
SINGLE_GATE=""
|
|
17
|
+
|
|
18
|
+
for arg in "$@"; do
|
|
19
|
+
case "$arg" in
|
|
20
|
+
--skip-appraisal) SKIP_APPRAISAL=true ;;
|
|
21
|
+
rspec|standardrb|reek|appraisal|audit) SINGLE_GATE="$arg" ;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
red() { printf '\033[31m%s\033[0m\n' "$*" >&2; }
|
|
26
|
+
green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
|
27
|
+
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
|
|
28
|
+
|
|
29
|
+
run_gate() {
|
|
30
|
+
local label="$1"; shift
|
|
31
|
+
dim "⏳ ${label}..."
|
|
32
|
+
if "$@" > /dev/null 2>&1; then
|
|
33
|
+
green "✅ ${label}"
|
|
34
|
+
return 0
|
|
35
|
+
else
|
|
36
|
+
red "❌ ${label} FAILED — check output above"
|
|
37
|
+
return 1
|
|
38
|
+
fi
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
gate_rspec() {
|
|
42
|
+
run_gate "RSpec" bundle exec rspec
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
gate_standardrb() {
|
|
46
|
+
run_gate "StandardRB" bundle exec standardrb
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
gate_reek() {
|
|
50
|
+
run_gate "Reek" bundle exec reek lib/ app/ db/
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
gate_appraisal() {
|
|
54
|
+
run_gate "Rails 8.0 appraisal" bundle exec appraisal rails-8.0 rspec \
|
|
55
|
+
&& run_gate "Rails 8.1 appraisal" bundle exec appraisal rails-8.1 rspec
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
gate_audit() {
|
|
59
|
+
dim "⏳ bundler-audit: updating advisory DB..."
|
|
60
|
+
bundle exec bundler-audit update > /dev/null 2>&1 || true
|
|
61
|
+
run_gate "bundler-audit" bundle exec bundler-audit check
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
run_all() {
|
|
65
|
+
local failed=0
|
|
66
|
+
|
|
67
|
+
gate_rspec || failed=$((failed + 1))
|
|
68
|
+
gate_standardrb || failed=$((failed + 1))
|
|
69
|
+
gate_reek || failed=$((failed + 1))
|
|
70
|
+
|
|
71
|
+
if [ "$SKIP_APPRAISAL" = false ]; then
|
|
72
|
+
gate_appraisal || failed=$((failed + 1))
|
|
73
|
+
else
|
|
74
|
+
dim "⏭️ Skipping appraisal (--skip-appraisal)"
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
gate_audit || failed=$((failed + 1))
|
|
78
|
+
|
|
79
|
+
echo ""
|
|
80
|
+
if [ "$failed" -eq 0 ]; then
|
|
81
|
+
green "All quality gates passed"
|
|
82
|
+
else
|
|
83
|
+
red "${failed} gate(s) failed"
|
|
84
|
+
return 1
|
|
85
|
+
fi
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case "$SINGLE_GATE" in
|
|
89
|
+
rspec) gate_rspec ;;
|
|
90
|
+
standardrb) gate_standardrb ;;
|
|
91
|
+
reek) gate_reek ;;
|
|
92
|
+
appraisal) gate_appraisal ;;
|
|
93
|
+
audit) gate_audit ;;
|
|
94
|
+
"") run_all ;;
|
|
95
|
+
esac
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
SolidObserver::Engine.routes.draw do
|
|
4
|
+
root "dashboard#index"
|
|
5
|
+
get "poll_data", to: "dashboard#poll_data", as: :poll_data
|
|
6
|
+
get "live_poll.js", to: "dashboard#live_poll", as: :live_poll_script
|
|
7
|
+
|
|
8
|
+
resources :jobs, only: %i[index show] do
|
|
9
|
+
member do
|
|
10
|
+
post :retry
|
|
11
|
+
post :discard
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
resource :storage, only: %i[show]
|
|
16
|
+
resources :events, only: %i[index show]
|
|
17
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddCompositeIndexesToQueueEvents < ActiveRecord::Migration[8.0]
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
def change
|
|
7
|
+
options = concurrent_supported? ? {algorithm: :concurrently} : {}
|
|
8
|
+
|
|
9
|
+
add_index :solid_observer_queue_events,
|
|
10
|
+
%i[job_class recorded_at],
|
|
11
|
+
order: {recorded_at: :desc},
|
|
12
|
+
if_not_exists: true,
|
|
13
|
+
**options
|
|
14
|
+
|
|
15
|
+
add_index :solid_observer_queue_events,
|
|
16
|
+
%i[queue_name recorded_at],
|
|
17
|
+
order: {recorded_at: :desc},
|
|
18
|
+
if_not_exists: true,
|
|
19
|
+
**options
|
|
20
|
+
|
|
21
|
+
remove_index :solid_observer_queue_events, :job_class, if_exists: true, **options
|
|
22
|
+
remove_index :solid_observer_queue_events, :queue_name, if_exists: true, **options
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def concurrent_supported?
|
|
28
|
+
connection.adapter_name.match?(/postgres/i)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -24,6 +24,10 @@ module SolidObserver
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def add_engine_mount
|
|
28
|
+
route 'mount SolidObserver::Engine, at: "/solid_observer"'
|
|
29
|
+
end
|
|
30
|
+
|
|
27
31
|
def show_instructions
|
|
28
32
|
say "\n"
|
|
29
33
|
print_banner
|
|
@@ -34,38 +38,21 @@ module SolidObserver
|
|
|
34
38
|
say " 3. Create database: bin/rails db:create"
|
|
35
39
|
say " 4. Run migrations: bin/rails db:migrate"
|
|
36
40
|
say " 5. Restart your Rails server"
|
|
41
|
+
say " 6. Visit /solid_observer to access the web dashboard"
|
|
37
42
|
say "\n"
|
|
38
|
-
say "Documentation: https://solid.observer", :
|
|
39
|
-
say "GitHub: https://github.com/bart-oz/solid_observer", :
|
|
43
|
+
say "Documentation: https://solid.observer", :red
|
|
44
|
+
say "GitHub: https://github.com/bart-oz/solid_observer", :red
|
|
40
45
|
say "\n"
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
private
|
|
44
49
|
|
|
45
50
|
def print_banner
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
╚════██║██║ ██║██║ ██║██║ ██║
|
|
52
|
-
███████║╚██████╔╝███████╗██║██████╔╝
|
|
53
|
-
╚══════╝ ╚═════╝ ╚══════╝╚═╝╚═════╝
|
|
54
|
-
|
|
55
|
-
██████╗ ██████╗ ███████╗███████╗██████╗ ██╗ ██╗███████╗██████╗
|
|
56
|
-
██╔═══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗██║ ██║██╔════╝██╔══██╗
|
|
57
|
-
██║ ██║██████╔╝███████╗█████╗ ██████╔╝██║ ██║█████╗ ██████╔╝
|
|
58
|
-
██║ ██║██╔══██╗╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██╔══╝ ██╔══██╗
|
|
59
|
-
╚██████╔╝██████╔╝███████║███████╗██║ ██║ ╚████╔╝ ███████╗██║ ██║
|
|
60
|
-
╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝
|
|
61
|
-
|
|
62
|
-
Observe your Solid Stack like a pro! 🔭
|
|
63
|
-
v#{SolidObserver::VERSION}
|
|
64
|
-
|
|
65
|
-
BANNER
|
|
66
|
-
|
|
67
|
-
banner.each_line { |line| say line.chomp, :cyan }
|
|
68
|
-
say " ✓ SolidObserver installed successfully!", :green
|
|
51
|
+
say " ┌─ ─┐", :red
|
|
52
|
+
say " ◉ solid_observer", :red
|
|
53
|
+
say " └─ ─┘", :red
|
|
54
|
+
say ""
|
|
55
|
+
say " ✓ SolidObserver installed successfully!", :green
|
|
69
56
|
end
|
|
70
57
|
end
|
|
71
58
|
end
|