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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/README.md +241 -59
  4. data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
  5. data/app/assets/stylesheets/solid_observer/application.css +18 -0
  6. data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
  7. data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
  8. data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
  9. data/app/controllers/solid_observer/application_controller.rb +69 -0
  10. data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
  11. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  12. data/app/controllers/solid_observer/dashboard_controller.rb +122 -0
  13. data/app/controllers/solid_observer/events_controller.rb +50 -0
  14. data/app/controllers/solid_observer/jobs_controller.rb +85 -0
  15. data/app/controllers/solid_observer/storages_controller.rb +12 -0
  16. data/app/helpers/solid_observer/application_helper.rb +244 -0
  17. data/app/helpers/solid_observer/dashboard_helper.rb +58 -0
  18. data/app/models/solid_observer/cache_event.rb +15 -0
  19. data/app/models/solid_observer/cache_metric.rb +14 -0
  20. data/app/models/solid_observer/queue_event.rb +134 -0
  21. data/app/models/solid_observer/queue_metric.rb +1 -1
  22. data/app/models/solid_observer/storage_info.rb +4 -1
  23. data/app/presenters/solid_observer/execution_presenter.rb +50 -0
  24. data/app/views/layouts/solid_observer/application.html.erb +597 -0
  25. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  26. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  27. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  28. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  29. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  30. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  31. data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
  32. data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
  33. data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
  34. data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
  35. data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
  36. data/app/views/solid_observer/dashboard/index.html.erb +143 -0
  37. data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
  38. data/app/views/solid_observer/events/index.html.erb +53 -0
  39. data/app/views/solid_observer/events/show.html.erb +47 -0
  40. data/app/views/solid_observer/jobs/index.html.erb +61 -0
  41. data/app/views/solid_observer/jobs/show.html.erb +71 -0
  42. data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
  43. data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
  44. data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
  45. data/app/views/solid_observer/storages/show.html.erb +71 -0
  46. data/bin/quality_gate +95 -0
  47. data/config/routes.rb +22 -0
  48. data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
  49. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  50. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  51. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  52. data/lib/generators/solid_observer/install_generator.rb +12 -25
  53. data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -6
  54. data/lib/solid_observer/base_metric.rb +1 -1
  55. data/lib/solid_observer/cache_event_buffer.rb +53 -0
  56. data/lib/solid_observer/cache_subscriber.rb +47 -0
  57. data/lib/solid_observer/chart_buffer.rb +83 -0
  58. data/lib/solid_observer/cli/base.rb +2 -2
  59. data/lib/solid_observer/cli/jobs.rb +2 -2
  60. data/lib/solid_observer/cli/status.rb +20 -2
  61. data/lib/solid_observer/cli/storage.rb +48 -44
  62. data/lib/solid_observer/configuration.rb +67 -38
  63. data/lib/solid_observer/correlation_id_resolver.rb +8 -6
  64. data/lib/solid_observer/engine.rb +110 -18
  65. data/lib/solid_observer/params/events_filter.rb +37 -0
  66. data/lib/solid_observer/params/jobs_filter.rb +35 -0
  67. data/lib/solid_observer/queries/events_query.rb +27 -0
  68. data/lib/solid_observer/queries/execution_finder.rb +42 -0
  69. data/lib/solid_observer/queries/job_executions_query.rb +73 -0
  70. data/lib/solid_observer/queue_event_buffer.rb +163 -25
  71. data/lib/solid_observer/queue_stats.rb +165 -19
  72. data/lib/solid_observer/services/cache_operations.rb +115 -0
  73. data/lib/solid_observer/services/cache_stats.rb +329 -0
  74. data/lib/solid_observer/services/cleanup_storage.rb +73 -41
  75. data/lib/solid_observer/services/database_size.rb +91 -0
  76. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  77. data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
  78. data/lib/solid_observer/services/install_migrations.rb +49 -0
  79. data/lib/solid_observer/services/record_cache_event.rb +142 -0
  80. data/lib/solid_observer/services/record_cache_metric.rb +74 -0
  81. data/lib/solid_observer/services/record_event.rb +51 -14
  82. data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
  83. data/lib/solid_observer/services/ui_auth_check.rb +65 -0
  84. data/lib/solid_observer/subscriber.rb +15 -8
  85. data/lib/solid_observer/version.rb +1 -1
  86. data/lib/solid_observer.rb +7 -0
  87. data/lib/tasks/solid_observer.rake +39 -2
  88. 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