solid_observer 0.1.1 → 0.4.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 +84 -0
- data/README.md +241 -59
- data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
- data/app/assets/stylesheets/solid_observer/application.css +18 -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/cache_dashboard_controller.rb +59 -0
- data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
- data/app/controllers/solid_observer/dashboard_controller.rb +122 -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 +244 -0
- data/app/helpers/solid_observer/dashboard_helper.rb +58 -0
- data/app/models/solid_observer/cache_event.rb +15 -0
- data/app/models/solid_observer/cache_metric.rb +14 -0
- data/app/models/solid_observer/queue_event.rb +134 -0
- data/app/models/solid_observer/queue_metric.rb +1 -1
- data/app/models/solid_observer/storage_info.rb +4 -1
- data/app/presenters/solid_observer/execution_presenter.rb +50 -0
- data/app/views/layouts/solid_observer/application.html.erb +597 -0
- data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
- data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
- data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
- data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
- data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
- data/app/views/solid_observer/cache_operations/index.html.erb +60 -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 +143 -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 +71 -0
- data/bin/quality_gate +95 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
- data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
- data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
- data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
- data/lib/generators/solid_observer/install_generator.rb +12 -25
- data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -6
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/cache_event_buffer.rb +53 -0
- data/lib/solid_observer/cache_subscriber.rb +47 -0
- 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 +48 -44
- data/lib/solid_observer/configuration.rb +67 -38
- data/lib/solid_observer/correlation_id_resolver.rb +8 -6
- data/lib/solid_observer/engine.rb +110 -18
- 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/cache_operations.rb +115 -0
- data/lib/solid_observer/services/cache_stats.rb +329 -0
- data/lib/solid_observer/services/cleanup_storage.rb +73 -41
- data/lib/solid_observer/services/database_size.rb +91 -0
- data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -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_cache_event.rb +142 -0
- data/lib/solid_observer/services/record_cache_metric.rb +74 -0
- data/lib/solid_observer/services/record_event.rb +51 -14
- data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
- 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 +39 -2
- metadata +77 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../helpers/solid_observer/application_helper"
|
|
4
|
+
|
|
5
|
+
module SolidObserver
|
|
6
|
+
class DashboardController < ApplicationController
|
|
7
|
+
helper SolidObserver::ApplicationHelper
|
|
8
|
+
|
|
9
|
+
skip_forgery_protection only: :live_poll
|
|
10
|
+
skip_after_action :verify_same_origin_request, only: :live_poll
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
@component = selected_component
|
|
14
|
+
return assign_cache_dashboard if @component == "cache"
|
|
15
|
+
|
|
16
|
+
return unless @component == "queue" && SolidObserver.config.solid_queue_enabled?
|
|
17
|
+
|
|
18
|
+
assign_range_and_stats
|
|
19
|
+
load_persistence_data if persistence_mode?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def live_poll
|
|
23
|
+
send_file(
|
|
24
|
+
SolidObserver::Engine.root.join("app/assets/javascripts/solid_observer/live_poll.js"),
|
|
25
|
+
type: "application/javascript; charset=utf-8",
|
|
26
|
+
disposition: "inline"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def poll_data
|
|
31
|
+
range = QueueStats.parse_range(request_range_param, fallback: QueueStats::POLL_DEFAULT_RANGE)
|
|
32
|
+
window = QueueStats.range_duration(range, fallback: QueueStats::POLL_DEFAULT_RANGE)
|
|
33
|
+
append_chart_buffer
|
|
34
|
+
render json: tick_request? ? tick_payload : full_payload(range: range, window: window)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def assign_range_and_stats
|
|
40
|
+
range = QueueStats.parse_range(request_range_param)
|
|
41
|
+
@range = range
|
|
42
|
+
@live = request_live_param == "on"
|
|
43
|
+
@stats = QueueStats.snapshot(range: range)
|
|
44
|
+
@chart = if @stats[:available]
|
|
45
|
+
QueueStats.chart_data(window: QueueStats.range_duration(@range))
|
|
46
|
+
else
|
|
47
|
+
{performed: [], failed: [], ready: []}
|
|
48
|
+
end
|
|
49
|
+
rescue
|
|
50
|
+
@chart = {performed: [], failed: [], ready: []}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def load_persistence_data
|
|
54
|
+
@recent_events = QueueEvent.recent(10)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def assign_cache_dashboard
|
|
58
|
+
SolidObserver::CacheDashboardController.cache_dashboard_assignments(range_param: request_range_param).each do |name, value|
|
|
59
|
+
instance_variable_set("@#{name}", value)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def request_range_param
|
|
64
|
+
request&.query_parameters&.[]("range") || request&.query_parameters&.[](:range)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def request_live_param
|
|
68
|
+
request&.query_parameters&.[]("live") || request&.query_parameters&.[](:live)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def request_tick_param
|
|
72
|
+
request&.query_parameters&.[]("tick") || request&.query_parameters&.[](:tick)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def tick_request?
|
|
76
|
+
request_tick_param == "true"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def tick_payload
|
|
80
|
+
{
|
|
81
|
+
mode: persistence_mode? ? "persistence" : "realtime",
|
|
82
|
+
snapshot: QueueStats.snapshot_for_tick,
|
|
83
|
+
chart: nil
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def full_payload(range:, window:)
|
|
88
|
+
{
|
|
89
|
+
mode: persistence_mode? ? "persistence" : "realtime",
|
|
90
|
+
snapshot: QueueStats.snapshot_for_poll(range: range),
|
|
91
|
+
chart: QueueStats.chart_data(window: window),
|
|
92
|
+
range_label: helpers.range_label(range)
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def append_chart_buffer
|
|
97
|
+
ChartBuffer.append(SolidQueue::ReadyExecution.count) if QueueStats.solid_queue_available?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def selected_component
|
|
101
|
+
requested = if request&.respond_to?(:path_parameters)
|
|
102
|
+
request.path_parameters&.[](:component).to_s
|
|
103
|
+
else
|
|
104
|
+
""
|
|
105
|
+
end
|
|
106
|
+
requested = path_component if requested.empty?
|
|
107
|
+
return "cache" if requested == "cache" && SolidObserver.config.solid_cache_enabled?
|
|
108
|
+
|
|
109
|
+
"queue"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def path_component
|
|
113
|
+
return "" unless request&.respond_to?(:path)
|
|
114
|
+
|
|
115
|
+
path = request&.path.to_s
|
|
116
|
+
return "cache" if path.end_with?("/cache")
|
|
117
|
+
return "queue" if path.end_with?("/queue")
|
|
118
|
+
|
|
119
|
+
""
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class EventsController < ApplicationController
|
|
5
|
+
include Paginatable
|
|
6
|
+
include RequirePersistenceMode
|
|
7
|
+
|
|
8
|
+
PER_PAGE = 50
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
filter = Params::EventsFilter.from_params(params)
|
|
12
|
+
@event_type = filter.event_type
|
|
13
|
+
@job_class = filter.job_class
|
|
14
|
+
@queue_name = filter.queue_name
|
|
15
|
+
@from = filter.from
|
|
16
|
+
@to = filter.to
|
|
17
|
+
@page = filter.page
|
|
18
|
+
scope = Queries::EventsQuery.new(filter).call
|
|
19
|
+
offset = paginate_scope(scope, per_page: PER_PAGE)
|
|
20
|
+
@events = scope.limit(PER_PAGE).offset(offset)
|
|
21
|
+
load_available_options
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
@event = QueueEvent.find_by(id: params[:id])
|
|
26
|
+
return redirect_to(events_path, alert: "Event not found") unless @event
|
|
27
|
+
|
|
28
|
+
@metadata = parse_metadata(@event.metadata)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def load_available_options
|
|
34
|
+
@available_event_types = QueueEvent::EVENT_TYPES
|
|
35
|
+
@available_job_classes = cached_filter_options("solid_observer/events/distinct_job_classes") { QueueEvent.distinct_job_classes }
|
|
36
|
+
@available_queues = cached_filter_options("solid_observer/events/distinct_queue_names") { QueueEvent.distinct_queue_names }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def cached_filter_options(key, &block)
|
|
40
|
+
Rails.cache.fetch(key, expires_in: SolidObserver.config.filter_cache_ttl, &block)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_metadata(metadata)
|
|
44
|
+
return nil if metadata.blank?
|
|
45
|
+
JSON.parse(metadata)
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
{raw: metadata}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class JobsController < ApplicationController
|
|
5
|
+
include Paginatable
|
|
6
|
+
include RequireSolidQueue
|
|
7
|
+
|
|
8
|
+
PER_PAGE = 25
|
|
9
|
+
CACHE_KEY_QUEUES = "solid_observer/jobs/available_queues"
|
|
10
|
+
CACHE_KEY_JOB_CLASSES = "solid_observer/jobs/available_job_classes"
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
filter = Params::JobsFilter.from_params(params)
|
|
14
|
+
@status = filter.status
|
|
15
|
+
@queue_name = filter.queue_name
|
|
16
|
+
@job_class = filter.job_class
|
|
17
|
+
@page = filter.page
|
|
18
|
+
result = Queries::JobExecutionsQuery.new(filter).call
|
|
19
|
+
offset = paginate_scope(result, per_page: PER_PAGE)
|
|
20
|
+
@jobs = if result.is_a?(Array)
|
|
21
|
+
result.drop(offset).first(PER_PAGE)
|
|
22
|
+
else
|
|
23
|
+
result.limit(PER_PAGE).offset(offset).includes(:job).to_a
|
|
24
|
+
end
|
|
25
|
+
@available_queues = fetch_available_queues
|
|
26
|
+
@available_job_classes = fetch_available_job_classes
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def show
|
|
30
|
+
@execution = find_execution_for_show
|
|
31
|
+
return redirect_to jobs_path, alert: "Job not found" unless @execution
|
|
32
|
+
|
|
33
|
+
assign_show_presenter
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def retry
|
|
37
|
+
id = params[:id]
|
|
38
|
+
execution = Queries::ExecutionFinder.find_failed(id)
|
|
39
|
+
return redirect_to(jobs_path, alert: "Failed job not found") unless execution
|
|
40
|
+
|
|
41
|
+
execution.retry
|
|
42
|
+
redirect_to jobs_path(status: "failed"), notice: "Job #{id} queued for retry"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def discard
|
|
46
|
+
id = params[:id]
|
|
47
|
+
execution = Queries::ExecutionFinder.find_failed(id)
|
|
48
|
+
return redirect_to(jobs_path, alert: "Failed job not found") unless execution
|
|
49
|
+
|
|
50
|
+
execution.discard
|
|
51
|
+
redirect_to jobs_path(status: "failed"), notice: "Job #{id} discarded"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def find_execution_for_show
|
|
57
|
+
id = params[:id]
|
|
58
|
+
Queries::ExecutionFinder.find_by_status(id, params[:status]) ||
|
|
59
|
+
Queries::ExecutionFinder.find_any(id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def assign_show_presenter
|
|
63
|
+
@presenter = ExecutionPresenter.new(@execution)
|
|
64
|
+
@job = @presenter.job
|
|
65
|
+
@status = @presenter.status
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fetch_available_queues
|
|
69
|
+
Rails.cache.fetch(CACHE_KEY_QUEUES, expires_in: SolidObserver.config.filter_cache_ttl) do
|
|
70
|
+
next [] unless defined?(SolidQueue::Queue)
|
|
71
|
+
SolidQueue::Queue.all.map(&:name)
|
|
72
|
+
end
|
|
73
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch_available_job_classes
|
|
78
|
+
Rails.cache.fetch(CACHE_KEY_JOB_CLASSES, expires_in: SolidObserver.config.filter_cache_ttl) do
|
|
79
|
+
SolidQueue::Job.distinct.pluck(:class_name).compact.sort
|
|
80
|
+
end
|
|
81
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
82
|
+
[]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class StoragesController < ApplicationController
|
|
5
|
+
include RequirePersistenceMode
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@storage_components = SolidObserver::Services::StorageInfoSnapshot.call
|
|
9
|
+
@storage_history = SolidObserver::StorageInfo.recent(20)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dashboard_helper"
|
|
4
|
+
|
|
5
|
+
module SolidObserver
|
|
6
|
+
module ApplicationHelper
|
|
7
|
+
include SolidObserver::DashboardHelper
|
|
8
|
+
|
|
9
|
+
STATUS_COLORS = {
|
|
10
|
+
"completed" => "success",
|
|
11
|
+
"ready" => "success",
|
|
12
|
+
"failed" => "danger",
|
|
13
|
+
"retry_stopped" => "danger",
|
|
14
|
+
"scheduled" => "warning",
|
|
15
|
+
"claimed" => "warning",
|
|
16
|
+
"enqueued" => "info",
|
|
17
|
+
"discarded" => "info"
|
|
18
|
+
}.freeze
|
|
19
|
+
DURATION_SEMANTICS = {
|
|
20
|
+
"job_enqueued" => "Time spent in the ActiveJob enqueue call (Rails internal; typically sub-millisecond to single-digit ms)",
|
|
21
|
+
"job_completed" => "Time spent performing the job",
|
|
22
|
+
"job_failed" => "Time spent performing the job before the exception was raised",
|
|
23
|
+
"job_discarded" => "Time before discard decision was made"
|
|
24
|
+
}.freeze
|
|
25
|
+
STABILITY_STATES = {
|
|
26
|
+
stable: {label: "Stable", tone: "success"},
|
|
27
|
+
degraded: {label: "Degraded", tone: "warning"},
|
|
28
|
+
critical: {label: "Critical", tone: "danger"}
|
|
29
|
+
}.freeze
|
|
30
|
+
CACHE_OUTCOME_STATES = {
|
|
31
|
+
hit: {label: "Hit", tone: "success"},
|
|
32
|
+
miss: {label: "Miss", tone: "info"},
|
|
33
|
+
error: {label: "Error", tone: "danger"},
|
|
34
|
+
recorded: {label: "Recorded", tone: "recorded"}
|
|
35
|
+
}.freeze
|
|
36
|
+
CACHE_RANGE_LABELS = {
|
|
37
|
+
"15m" => "in last 15m",
|
|
38
|
+
"30m" => "in last 30m",
|
|
39
|
+
"1h" => "in last hour",
|
|
40
|
+
"7h" => "in last 7h",
|
|
41
|
+
"1d" => "in last day",
|
|
42
|
+
"7d" => "in last 7d",
|
|
43
|
+
"14d" => "in last 14d"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
def execution_status(execution)
|
|
47
|
+
ExecutionPresenter.new(execution).status
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def format_duration(seconds)
|
|
51
|
+
return "0ms" if seconds.to_f.zero?
|
|
52
|
+
|
|
53
|
+
if seconds < 1
|
|
54
|
+
"#{(seconds * 1000).round}ms"
|
|
55
|
+
else
|
|
56
|
+
"#{"%.1f" % seconds}s"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def status_badge(status)
|
|
61
|
+
status_str = status.to_s
|
|
62
|
+
color = STATUS_COLORS.fetch(status_str, "default")
|
|
63
|
+
dot = tag.svg(
|
|
64
|
+
tag.circle(r: 3, cx: 3, cy: 3),
|
|
65
|
+
class: "so-badge__dot",
|
|
66
|
+
viewBox: "0 0 6 6",
|
|
67
|
+
"aria-hidden": "true"
|
|
68
|
+
)
|
|
69
|
+
tag.span(class: "so-badge so-badge--pill so-badge--#{color}") do
|
|
70
|
+
safe_join([dot, status_str.humanize], " ")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def duration_with_semantic(value, event_type)
|
|
75
|
+
return content_tag(:span, "—", class: "so-text-muted") unless value
|
|
76
|
+
|
|
77
|
+
content_tag(:abbr, format_duration(value), title: DURATION_SEMANTICS.fetch(event_type.to_s))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def mode_badge
|
|
81
|
+
config = SolidObserver.config
|
|
82
|
+
color = config.persistence_mode? ? "info" : "warning"
|
|
83
|
+
dot = tag.svg(
|
|
84
|
+
tag.circle(r: 3, cx: 3, cy: 3),
|
|
85
|
+
class: "so-badge__dot",
|
|
86
|
+
viewBox: "0 0 6 6",
|
|
87
|
+
"aria-hidden": "true"
|
|
88
|
+
)
|
|
89
|
+
tag.span(class: "so-badge so-badge--pill so-badge--#{color}") do
|
|
90
|
+
safe_join([dot, config.storage_mode.to_s.capitalize], " ")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def turbo_frame_tag(id, **options, &block)
|
|
95
|
+
return super if defined?(super)
|
|
96
|
+
|
|
97
|
+
content = options.delete(:content)
|
|
98
|
+
body = block_given? ? capture(&block) : content
|
|
99
|
+
|
|
100
|
+
content_tag(:"turbo-frame", body, **options.merge(id: id).compact)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stability_state(stats)
|
|
104
|
+
return :critical if stats[:failed_last_hour].to_i.positive?
|
|
105
|
+
return :degraded if stats[:failed_last_24h].to_i.positive?
|
|
106
|
+
|
|
107
|
+
:stable
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def stability_badge(stats)
|
|
111
|
+
stability_badge_for(stability_state(stats))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cache_stability_badge(state)
|
|
115
|
+
stability_badge_for(state.to_sym)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def cache_ratio_percent(value)
|
|
119
|
+
number_to_percentage(value.to_f * 100, precision: 1, strip_insignificant_zeros: true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def cache_storage_summary(storage_components)
|
|
123
|
+
snapshots = Array(storage_components)
|
|
124
|
+
reason = cache_storage_unavailable_reason(snapshots)
|
|
125
|
+
return {value: "—", subtitle: "— #{reason}"} if reason
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
value: number_to_human_size(cache_storage_total_bytes(snapshots), precision: 1, significant: false, strip_insignificant_zeros: false),
|
|
129
|
+
subtitle: "SolidCache + cache observer"
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def cache_event_outcome_badge(event)
|
|
134
|
+
meta = cache_event_outcome_meta(event)
|
|
135
|
+
dot = tag.svg(
|
|
136
|
+
tag.circle(r: 3, cx: 3, cy: 3),
|
|
137
|
+
class: "so-badge__dot",
|
|
138
|
+
viewBox: "0 0 6 6",
|
|
139
|
+
"aria-hidden": "true"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
tag.span(class: "so-badge so-badge--pill so-badge--#{meta[:tone]}") do
|
|
143
|
+
safe_join([dot, meta[:label]], " ")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def cache_event_digest(key_digest, visible_chars: 10)
|
|
148
|
+
digest = key_digest.to_s
|
|
149
|
+
return "—" if digest.empty?
|
|
150
|
+
return digest if digest.length <= visible_chars
|
|
151
|
+
|
|
152
|
+
"#{digest.first(visible_chars)}…"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def cache_range_label(range_key)
|
|
156
|
+
CACHE_RANGE_LABELS.fetch(range_key.to_s, "in selected range")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def cache_stability_detail(stability)
|
|
160
|
+
state = (stability || {})[:state]&.to_sym
|
|
161
|
+
state = :stable unless STABILITY_STATES.key?(state)
|
|
162
|
+
|
|
163
|
+
case state
|
|
164
|
+
when :critical
|
|
165
|
+
critical_cache_stability_detail(stability)
|
|
166
|
+
when :degraded
|
|
167
|
+
degraded_cache_stability_detail(stability)
|
|
168
|
+
else
|
|
169
|
+
"No sampled cache errors or slow events in the selected range"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def stability_detail(stats)
|
|
174
|
+
failures_24h = stats[:failed_last_24h].to_i
|
|
175
|
+
return "No failures in the last 24h" if failures_24h.zero?
|
|
176
|
+
|
|
177
|
+
"#{pluralize(failures_24h, "failure")} in the last 24h, latest #{latest_failure_phrase(stats[:latest_failure_at])}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def latest_failure_phrase(timestamp)
|
|
181
|
+
timestamp ? "#{time_ago_in_words(timestamp)} ago" : "unknown"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def queue_component_enabled?
|
|
185
|
+
SolidObserver.config.solid_queue_enabled?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def cache_component_enabled?
|
|
189
|
+
SolidObserver.config.solid_cache_enabled?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def dashboard_section_active?(component)
|
|
193
|
+
current_component = @component.presence || "queue"
|
|
194
|
+
controller_name == "dashboard" && current_component == component.to_s
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def stability_badge_for(state)
|
|
200
|
+
meta = STABILITY_STATES.fetch(state)
|
|
201
|
+
dot = tag.svg(tag.circle(r: 3, cx: 3, cy: 3),
|
|
202
|
+
class: "so-badge__dot", viewBox: "0 0 6 6", "aria-hidden": "true")
|
|
203
|
+
tag.span(class: "so-badge so-badge--pill so-badge--#{meta[:tone]}") do
|
|
204
|
+
safe_join([dot, meta[:label]], " ")
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def critical_cache_stability_detail(stability)
|
|
209
|
+
detail = pluralize(stability[:error_count].to_i, "sampled cache error")
|
|
210
|
+
slow_count = stability[:slow_count].to_i
|
|
211
|
+
detail = "#{detail} and #{pluralize(slow_count, "slow event")}" if slow_count.positive?
|
|
212
|
+
"#{detail} in the selected range#{cache_stability_latest_suffix(stability[:latest_recorded_at])}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def degraded_cache_stability_detail(stability)
|
|
216
|
+
detail = pluralize(stability[:slow_count].to_i, "slow sampled cache event")
|
|
217
|
+
"#{detail} in the selected range#{cache_stability_latest_suffix(stability[:latest_recorded_at])}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def cache_stability_latest_suffix(timestamp)
|
|
221
|
+
timestamp ? ", latest #{time_ago_in_words(timestamp)} ago" : ""
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def cache_storage_total_bytes(snapshots)
|
|
225
|
+
snapshots.sum { |snapshot| snapshot[:db_size_bytes].to_i }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def cache_storage_unavailable_reason(snapshots)
|
|
229
|
+
return "Storage snapshot unavailable" unless snapshots.size == 2
|
|
230
|
+
|
|
231
|
+
snapshots.find { |snapshot| !snapshot[:available] }&.[](:unavailable_reason)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def cache_event_outcome_meta(event)
|
|
235
|
+
hit = event.hit
|
|
236
|
+
|
|
237
|
+
return CACHE_OUTCOME_STATES.fetch(:error) if event.error_class.present?
|
|
238
|
+
return CACHE_OUTCOME_STATES.fetch(:hit) if hit == true
|
|
239
|
+
return CACHE_OUTCOME_STATES.fetch(:miss) if hit == false
|
|
240
|
+
|
|
241
|
+
CACHE_OUTCOME_STATES.fetch(:recorded)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mirrors live_poll.js Sparkline.render — keep in sync.
|
|
4
|
+
module SolidObserver
|
|
5
|
+
module DashboardHelper
|
|
6
|
+
SVG_W = 120
|
|
7
|
+
SVG_H = 32
|
|
8
|
+
|
|
9
|
+
def spark_points(series, width: SVG_W, height: SVG_H)
|
|
10
|
+
return "" if series.blank?
|
|
11
|
+
|
|
12
|
+
context = build_spark_context(series, width, height)
|
|
13
|
+
series.map { |point| format_spark_point(point: point, context: context) }.join(" ")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build_spark_context(series, width, height)
|
|
17
|
+
first_point = series.first
|
|
18
|
+
last_point = series.last
|
|
19
|
+
min_time = first_point[:t]
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
min_time: min_time,
|
|
23
|
+
time_span: last_point[:t] - min_time,
|
|
24
|
+
max_value: [series.max_by { |point| point[:v] }[:v], 1].max,
|
|
25
|
+
width: width,
|
|
26
|
+
height: height,
|
|
27
|
+
inner_width: width - 2,
|
|
28
|
+
inner_height: height - 2
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def format_spark_point(point:, context:)
|
|
33
|
+
time_span = context[:time_span]
|
|
34
|
+
coordinate_x = if time_span.zero?
|
|
35
|
+
context[:width] / 2.0
|
|
36
|
+
else
|
|
37
|
+
((point[:t] - context[:min_time]).to_f / time_span) * context[:inner_width] + 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
coordinate_y = context[:height] - 1 - (point[:v].to_f / context[:max_value]) * context[:inner_height]
|
|
41
|
+
format("%.1f,%.1f", coordinate_x, coordinate_y)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
RANGE_LABELS = {
|
|
45
|
+
"15m" => "in last 15m",
|
|
46
|
+
"30m" => "in last 30m",
|
|
47
|
+
"1h" => "in last hour",
|
|
48
|
+
"7h" => "in last 7h",
|
|
49
|
+
"1d" => "in last day",
|
|
50
|
+
"7d" => "in last 7d",
|
|
51
|
+
"14d" => "in last 14d"
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
def range_label(range_key)
|
|
55
|
+
RANGE_LABELS.fetch(range_key, "in selected range")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class CacheEvent < BaseEvent
|
|
5
|
+
self.table_name = "solid_observer_cache_events"
|
|
6
|
+
|
|
7
|
+
validates :event_type, presence: true
|
|
8
|
+
validates :key_digest, presence: true
|
|
9
|
+
validates :recorded_at, presence: true
|
|
10
|
+
|
|
11
|
+
scope :errored, -> { where.not(error_class: nil) }
|
|
12
|
+
scope :slow, ->(threshold = SolidObserver.config.cache_slow_threshold) { where("duration >= ?", threshold) }
|
|
13
|
+
scope :recent, ->(limit = 10) { order(recorded_at: :desc).limit(limit) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class CacheMetric < BaseMetric
|
|
5
|
+
self.table_name = "solid_observer_cache_metrics"
|
|
6
|
+
clear_validators!
|
|
7
|
+
|
|
8
|
+
validates :event_type, presence: true, length: {maximum: 64}
|
|
9
|
+
validates :period_start, presence: true
|
|
10
|
+
validates :operations_count, :hits_count, :misses_count, :errors_count,
|
|
11
|
+
numericality: {only_integer: true, greater_than_or_equal_to: 0}
|
|
12
|
+
validates :duration_total, numericality: {greater_than_or_equal_to: 0}
|
|
13
|
+
end
|
|
14
|
+
end
|