solid_observer 0.1.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 +7 -0
- data/CHANGELOG.md +58 -0
- data/LICENSE.txt +21 -0
- data/README.md +347 -0
- data/app/jobs/solid_observer/cleanup_job.rb +12 -0
- data/app/models/solid_observer/queue_event.rb +23 -0
- data/app/models/solid_observer/queue_metric.rb +14 -0
- data/app/models/solid_observer/storage_info.rb +36 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/db/migrate/20260115000001_create_solid_observer_queue_events.rb +21 -0
- data/db/migrate/20260115000002_create_solid_observer_metrics.rb +16 -0
- data/db/migrate/20260115000003_create_solid_observer_storage_info.rb +13 -0
- data/lib/generators/solid_observer/install_generator.rb +72 -0
- data/lib/generators/solid_observer/templates/initializer.rb.tt +57 -0
- data/lib/solid_observer/base_event.rb +10 -0
- data/lib/solid_observer/base_metric.rb +59 -0
- data/lib/solid_observer/cli/base.rb +98 -0
- data/lib/solid_observer/cli/jobs.rb +195 -0
- data/lib/solid_observer/cli/status.rb +59 -0
- data/lib/solid_observer/cli/storage.rb +114 -0
- data/lib/solid_observer/configuration.rb +125 -0
- data/lib/solid_observer/correlation_id_resolver.rb +62 -0
- data/lib/solid_observer/engine.rb +60 -0
- data/lib/solid_observer/queue_event_buffer.rb +80 -0
- data/lib/solid_observer/queue_stats.rb +89 -0
- data/lib/solid_observer/services/cleanup_storage.rb +94 -0
- data/lib/solid_observer/services/flush_event_buffer.rb +65 -0
- data/lib/solid_observer/services/record_event.rb +96 -0
- data/lib/solid_observer/subscriber.rb +96 -0
- data/lib/solid_observer/version.rb +7 -0
- data/lib/solid_observer.rb +40 -0
- data/lib/tasks/solid_observer.rake +155 -0
- metadata +93 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SolidObserver Configuration
|
|
4
|
+
# See https://github.com/bart-oz/solid_observer for more details
|
|
5
|
+
|
|
6
|
+
SolidObserver.configure do |config|
|
|
7
|
+
# UI Settings
|
|
8
|
+
# Enable the web UI for viewing events and metrics
|
|
9
|
+
# Recommended: false in production, true in development/staging
|
|
10
|
+
config.ui_enabled = !Rails.env.production?
|
|
11
|
+
|
|
12
|
+
# Authentication for UI (when ui_enabled = true)
|
|
13
|
+
# config.http_basic_auth_enabled = true
|
|
14
|
+
# config.http_basic_auth_user = "admin"
|
|
15
|
+
# config.http_basic_auth_password = "secret"
|
|
16
|
+
|
|
17
|
+
# Base controller for UI (customize authorization)
|
|
18
|
+
# config.ui_base_controller = "ApplicationController"
|
|
19
|
+
|
|
20
|
+
# === Queue Observability (v0.1.0) ===
|
|
21
|
+
config.observe_queue = true
|
|
22
|
+
|
|
23
|
+
# === Cache Observability (Coming in v0.2.0+) ===
|
|
24
|
+
# config.observe_cache = true
|
|
25
|
+
# config.cache_sampling_rate = 0.1 # Sample 10% of cache operations
|
|
26
|
+
|
|
27
|
+
# === Cable Observability (Coming in v0.2.0+) ===
|
|
28
|
+
# config.observe_cable = true
|
|
29
|
+
|
|
30
|
+
# Data Retention
|
|
31
|
+
config.event_retention = 30.days # How long to keep event records
|
|
32
|
+
config.metrics_retention = 90.days # How long to keep aggregated metrics
|
|
33
|
+
|
|
34
|
+
# Database Limits (prevent unlimited growth)
|
|
35
|
+
config.max_db_size = 1.gigabyte
|
|
36
|
+
config.warning_threshold = 0.8 # Warn when DB reaches 80% of max_db_size
|
|
37
|
+
|
|
38
|
+
# Performance Settings
|
|
39
|
+
config.buffer_size = 1000 # Events to buffer before flushing
|
|
40
|
+
config.flush_interval = 10.seconds
|
|
41
|
+
|
|
42
|
+
# Sampling (reduce overhead in high-traffic apps)
|
|
43
|
+
config.sampling_rate = 1.0 # 1.0 = 100% (capture all events)
|
|
44
|
+
|
|
45
|
+
# Correlation ID (for distributed tracing)
|
|
46
|
+
# Integrate with your APM tool (Datadog, Sentry, OpenTelemetry, etc.)
|
|
47
|
+
# config.correlation_id_generator = -> {
|
|
48
|
+
# # Example for Datadog APM
|
|
49
|
+
# Datadog::Tracing.active_trace&.id
|
|
50
|
+
#
|
|
51
|
+
# # Example for Sentry
|
|
52
|
+
# Sentry.get_current_scope&.transaction&.trace_id
|
|
53
|
+
#
|
|
54
|
+
# # Example for OpenTelemetry
|
|
55
|
+
# OpenTelemetry::Trace.current_span&.context&.trace_id
|
|
56
|
+
# }
|
|
57
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
# BaseMetric provides the foundation for time-series metrics storage.
|
|
5
|
+
#
|
|
6
|
+
# NOTE: Metrics functionality is planned for v0.2.0. The database connection
|
|
7
|
+
# will be configured by the Engine (similar to BaseEvent) when metrics are
|
|
8
|
+
# fully implemented.
|
|
9
|
+
#
|
|
10
|
+
class BaseMetric < ActiveRecord::Base
|
|
11
|
+
self.abstract_class = true
|
|
12
|
+
self.table_name = "solid_observer_metrics"
|
|
13
|
+
|
|
14
|
+
PERIOD_TYPES = %w[minute hour day].freeze
|
|
15
|
+
|
|
16
|
+
validates :metric_name, presence: true
|
|
17
|
+
validates :value, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
|
|
18
|
+
validates :period_start, presence: true
|
|
19
|
+
validates :period_type, presence: true, inclusion: {in: PERIOD_TYPES}
|
|
20
|
+
|
|
21
|
+
scope :for_metric, ->(name) { where(metric_name: name) }
|
|
22
|
+
scope :hourly, -> { where(period_type: "hour") }
|
|
23
|
+
scope :daily, -> { where(period_type: "day") }
|
|
24
|
+
scope :minutely, -> { where(period_type: "minute") }
|
|
25
|
+
scope :since, ->(time) { where("period_start >= ?", time) }
|
|
26
|
+
scope :between, ->(start_time, end_time) { where(period_start: start_time..end_time) }
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def increment(metric:, period: Time.current.beginning_of_hour, period_type: "hour", by: 1)
|
|
30
|
+
record = find_or_create_by!(
|
|
31
|
+
metric_name: metric,
|
|
32
|
+
period_start: period,
|
|
33
|
+
period_type: period_type
|
|
34
|
+
)
|
|
35
|
+
record.increment!(:value, by)
|
|
36
|
+
record
|
|
37
|
+
rescue ActiveRecord::RecordNotUnique
|
|
38
|
+
retry
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def record(metric:, value:, period: Time.current.beginning_of_hour, period_type: "hour")
|
|
42
|
+
upsert(
|
|
43
|
+
{
|
|
44
|
+
metric_name: metric,
|
|
45
|
+
value: value,
|
|
46
|
+
period_start: period,
|
|
47
|
+
period_type: period_type
|
|
48
|
+
},
|
|
49
|
+
unique_by: [:metric_name, :period_start, :period_type]
|
|
50
|
+
)
|
|
51
|
+
find_by!(
|
|
52
|
+
metric_name: metric,
|
|
53
|
+
period_start: period,
|
|
54
|
+
period_type: period_type
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module CLI
|
|
5
|
+
class Base
|
|
6
|
+
def self.call(*args, **kwargs)
|
|
7
|
+
new.call(*args, **kwargs)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def output(text, color: nil)
|
|
17
|
+
text = colorize(text, color) if color && color_enabled?
|
|
18
|
+
puts text
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def error(text)
|
|
22
|
+
output(text, color: :red)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def success(text)
|
|
26
|
+
output(text, color: :green)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def warning(text)
|
|
30
|
+
output(text, color: :yellow)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def info(text)
|
|
34
|
+
output(text, color: :blue)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def table(headers:, rows:)
|
|
38
|
+
return if rows.empty?
|
|
39
|
+
|
|
40
|
+
widths = calculate_column_widths(headers, rows)
|
|
41
|
+
|
|
42
|
+
output(format_table_row(headers, widths), color: :cyan)
|
|
43
|
+
output(separator_line(widths), color: :cyan)
|
|
44
|
+
|
|
45
|
+
rows.each do |row|
|
|
46
|
+
output(format_table_row(row, widths))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def confirm(question, default: true)
|
|
51
|
+
prompt = default ? "(Y/n)" : "(y/N)"
|
|
52
|
+
output("#{question} #{prompt} ", color: :yellow)
|
|
53
|
+
print "> "
|
|
54
|
+
|
|
55
|
+
response = $stdin.gets&.strip&.downcase
|
|
56
|
+
|
|
57
|
+
return default if response.nil? || response.empty?
|
|
58
|
+
|
|
59
|
+
response.start_with?("y")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def color_enabled?
|
|
63
|
+
$stdout.tty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def colorize(text, color)
|
|
67
|
+
return text unless color
|
|
68
|
+
|
|
69
|
+
color_code = COLORS[color]
|
|
70
|
+
return text unless color_code
|
|
71
|
+
|
|
72
|
+
"\e[#{color_code}m#{text}\e[0m"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def calculate_column_widths(headers, rows)
|
|
76
|
+
all_rows = [headers] + rows
|
|
77
|
+
all_rows.transpose.map { |column| column.map(&:to_s).map(&:length).max }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def format_table_row(row, widths)
|
|
81
|
+
row.map.with_index { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def separator_line(widths)
|
|
85
|
+
widths.map { |width| "-" * width }.join(" ")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
COLORS = {
|
|
89
|
+
red: 31,
|
|
90
|
+
green: 32,
|
|
91
|
+
yellow: 33,
|
|
92
|
+
blue: 34,
|
|
93
|
+
magenta: 35,
|
|
94
|
+
cyan: 36
|
|
95
|
+
}.freeze
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module CLI
|
|
5
|
+
class Jobs < Base
|
|
6
|
+
def list(status: nil, queue: nil, job_class: nil, limit: 20)
|
|
7
|
+
return error("SolidQueue is not available") unless solid_queue_available?
|
|
8
|
+
|
|
9
|
+
jobs = fetch_jobs(status: status, queue: queue, job_class: job_class, limit: limit)
|
|
10
|
+
|
|
11
|
+
return warning("No jobs found matching the criteria") if jobs.empty?
|
|
12
|
+
|
|
13
|
+
print_jobs_table(jobs)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show(job_id)
|
|
17
|
+
return error("SolidQueue is not available") unless solid_queue_available?
|
|
18
|
+
|
|
19
|
+
job = find_job(job_id)
|
|
20
|
+
return unless job
|
|
21
|
+
|
|
22
|
+
print_job_details(job)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def retry_job(job_id)
|
|
26
|
+
return error("SolidQueue is not available") unless solid_queue_available?
|
|
27
|
+
|
|
28
|
+
job = find_failed_job(job_id)
|
|
29
|
+
return unless job
|
|
30
|
+
|
|
31
|
+
return info("Retry cancelled") unless confirm("Retry job #{job_id}?")
|
|
32
|
+
|
|
33
|
+
job.retry
|
|
34
|
+
success("✓ Job #{job_id} has been queued for retry")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def discard(job_id)
|
|
38
|
+
return error("SolidQueue is not available") unless solid_queue_available?
|
|
39
|
+
|
|
40
|
+
job = find_failed_job(job_id)
|
|
41
|
+
return unless job
|
|
42
|
+
|
|
43
|
+
return info("Discard cancelled") unless confirm("Discard job #{job_id}? This cannot be undone.", default: false)
|
|
44
|
+
|
|
45
|
+
job.discard
|
|
46
|
+
success("✓ Job #{job_id} has been discarded")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def solid_queue_available?
|
|
52
|
+
SolidObserver::QueueStats.solid_queue_available?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch_jobs(status:, queue:, job_class:, limit:)
|
|
56
|
+
scope = scope_for_status(status)
|
|
57
|
+
scope = scope.joins(:job) if job_class || needs_job_join?(status, queue)
|
|
58
|
+
scope = apply_queue_filter(scope, status, queue)
|
|
59
|
+
scope = scope.where("solid_queue_jobs.class_name = ?", job_class) if job_class
|
|
60
|
+
scope.order(created_at: :desc).limit(limit.to_i).includes(:job).to_a
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def needs_job_join?(status, queue)
|
|
64
|
+
queue && %w[failed claimed].include?(status&.to_s&.downcase)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def apply_queue_filter(scope, status, queue)
|
|
68
|
+
return scope unless queue
|
|
69
|
+
|
|
70
|
+
case status&.to_s&.downcase
|
|
71
|
+
when "failed", "claimed"
|
|
72
|
+
scope.where("solid_queue_jobs.queue_name = ?", queue)
|
|
73
|
+
else
|
|
74
|
+
scope.where(queue_name: queue)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def scope_for_status(status)
|
|
79
|
+
case status&.to_s&.downcase
|
|
80
|
+
when "ready" then SolidQueue::ReadyExecution.all
|
|
81
|
+
when "scheduled" then SolidQueue::ScheduledExecution.all
|
|
82
|
+
when "claimed" then SolidQueue::ClaimedExecution.all
|
|
83
|
+
when "failed" then SolidQueue::FailedExecution.all
|
|
84
|
+
else SolidQueue::ReadyExecution.all
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def find_job(job_id)
|
|
89
|
+
job = find_in_execution_tables(job_id)
|
|
90
|
+
return job if job
|
|
91
|
+
|
|
92
|
+
error("Job #{job_id} not found")
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def find_failed_job(job_id)
|
|
97
|
+
job = SolidQueue::FailedExecution.find_by(id: job_id)
|
|
98
|
+
return job if job
|
|
99
|
+
|
|
100
|
+
error("Failed job #{job_id} not found")
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def find_in_execution_tables(job_id)
|
|
105
|
+
SolidQueue::ReadyExecution.find_by(id: job_id) ||
|
|
106
|
+
SolidQueue::ScheduledExecution.find_by(id: job_id) ||
|
|
107
|
+
SolidQueue::ClaimedExecution.find_by(id: job_id) ||
|
|
108
|
+
SolidQueue::FailedExecution.find_by(id: job_id)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def print_jobs_table(jobs)
|
|
112
|
+
print_section_header("📋 Jobs")
|
|
113
|
+
table(
|
|
114
|
+
headers: ["ID", "Queue", "Class", "Status", "Created At"],
|
|
115
|
+
rows: jobs.map { |job| format_job_row(job) }
|
|
116
|
+
)
|
|
117
|
+
output("")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def format_job_row(execution)
|
|
121
|
+
job = execution.job
|
|
122
|
+
[
|
|
123
|
+
execution.id.to_s,
|
|
124
|
+
job&.queue_name || execution.try(:queue_name) || "N/A",
|
|
125
|
+
job&.class_name || "N/A",
|
|
126
|
+
job_status(execution),
|
|
127
|
+
format_time(execution.created_at)
|
|
128
|
+
]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def job_status(execution)
|
|
132
|
+
return "Ready" if execution.is_a?(SolidQueue::ReadyExecution)
|
|
133
|
+
return "Scheduled" if execution.is_a?(SolidQueue::ScheduledExecution)
|
|
134
|
+
return "Claimed" if execution.is_a?(SolidQueue::ClaimedExecution)
|
|
135
|
+
return "Failed" if execution.is_a?(SolidQueue::FailedExecution)
|
|
136
|
+
|
|
137
|
+
"Unknown"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def format_time(time)
|
|
141
|
+
time.strftime("%Y-%m-%d %H:%M:%S")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def print_job_details(execution)
|
|
145
|
+
print_section_header("📄 Job Details")
|
|
146
|
+
|
|
147
|
+
details = build_job_details(execution)
|
|
148
|
+
|
|
149
|
+
table(
|
|
150
|
+
headers: ["Attribute", "Value"],
|
|
151
|
+
rows: details
|
|
152
|
+
)
|
|
153
|
+
output("")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def build_job_details(execution)
|
|
157
|
+
job = execution.job
|
|
158
|
+
details = [
|
|
159
|
+
["ID", execution.id],
|
|
160
|
+
["Job ID", job&.id || "N/A"],
|
|
161
|
+
["Queue", job&.queue_name || execution.try(:queue_name) || "N/A"],
|
|
162
|
+
["Class", job&.class_name || "N/A"],
|
|
163
|
+
["Status", job_status(execution)],
|
|
164
|
+
["Created At", format_time(execution.created_at)],
|
|
165
|
+
["Priority", execution.try(:priority) || job&.priority || "N/A"]
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
add_scheduled_details(details, execution)
|
|
169
|
+
add_error_details(details, execution)
|
|
170
|
+
|
|
171
|
+
details
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def add_scheduled_details(details, execution)
|
|
175
|
+
return unless execution.is_a?(SolidQueue::ScheduledExecution)
|
|
176
|
+
|
|
177
|
+
details << ["Scheduled At", format_time(execution.scheduled_at)]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def add_error_details(details, execution)
|
|
181
|
+
return unless execution.is_a?(SolidQueue::FailedExecution)
|
|
182
|
+
return unless execution.error
|
|
183
|
+
|
|
184
|
+
details << ["Error", execution.error.exception_class]
|
|
185
|
+
details << ["Error Message", execution.error.message]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def print_section_header(title)
|
|
189
|
+
output("\n#{title}", color: :cyan)
|
|
190
|
+
output("=" * 80, color: :cyan)
|
|
191
|
+
output("")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module CLI
|
|
5
|
+
class Status < Base
|
|
6
|
+
def call
|
|
7
|
+
print_header
|
|
8
|
+
print_queue_stats
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def print_header
|
|
14
|
+
output("\n📊 SolidObserver Status", color: :cyan)
|
|
15
|
+
output("=" * 50, color: :cyan)
|
|
16
|
+
output("")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def print_queue_stats
|
|
20
|
+
stats = SolidObserver::QueueStats.snapshot
|
|
21
|
+
|
|
22
|
+
if stats[:available]
|
|
23
|
+
print_queue_table(stats)
|
|
24
|
+
print_queue_depths(stats[:queues]) if stats[:queues].any?
|
|
25
|
+
else
|
|
26
|
+
warning("⚠️ SolidQueue not available: #{stats[:error]}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def print_queue_table(stats)
|
|
31
|
+
output("🚀 Solid Queue", color: :green)
|
|
32
|
+
output("")
|
|
33
|
+
|
|
34
|
+
table(
|
|
35
|
+
headers: ["Metric", "Value"],
|
|
36
|
+
rows: [
|
|
37
|
+
["Ready", stats[:ready]],
|
|
38
|
+
["Scheduled", stats[:scheduled]],
|
|
39
|
+
["Claimed", stats[:claimed]],
|
|
40
|
+
["Failed", stats[:failed]],
|
|
41
|
+
["Workers", stats[:workers]]
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
output("")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def print_queue_depths(queues)
|
|
48
|
+
output("📋 Queue Depths", color: :green)
|
|
49
|
+
output("")
|
|
50
|
+
|
|
51
|
+
table(
|
|
52
|
+
headers: ["Queue", "Jobs"],
|
|
53
|
+
rows: queues.sort_by { |name, _count| name }.map { |name, count| [name, count] }
|
|
54
|
+
)
|
|
55
|
+
output("")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module CLI
|
|
5
|
+
class Storage < Base
|
|
6
|
+
def call
|
|
7
|
+
print_section_header("💾 Storage Status")
|
|
8
|
+
|
|
9
|
+
current_stats = gather_storage_stats
|
|
10
|
+
|
|
11
|
+
if current_stats[:error]
|
|
12
|
+
error(current_stats[:error])
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
print_storage_table(current_stats)
|
|
17
|
+
print_configuration
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def gather_storage_stats
|
|
23
|
+
{
|
|
24
|
+
db_size_bytes: calculate_database_size,
|
|
25
|
+
event_count: QueueEvent.count,
|
|
26
|
+
max_size_bytes: SolidObserver.config.max_db_size
|
|
27
|
+
}
|
|
28
|
+
rescue => e
|
|
29
|
+
{error: "Failed to gather storage stats: #{e.message}"}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def calculate_database_size
|
|
33
|
+
db_config = QueueEvent.connection_db_config
|
|
34
|
+
db_path = db_config.database
|
|
35
|
+
|
|
36
|
+
return 0 unless File.exist?(db_path)
|
|
37
|
+
|
|
38
|
+
File.size(db_path)
|
|
39
|
+
rescue => e
|
|
40
|
+
warning("Could not calculate database size: #{e.message}")
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def print_storage_table(stats)
|
|
45
|
+
size_mb = bytes_to_mb(stats[:db_size_bytes])
|
|
46
|
+
percentage = calculate_percentage(stats[:db_size_bytes], stats[:max_size_bytes])
|
|
47
|
+
status = status_indicator(percentage)
|
|
48
|
+
|
|
49
|
+
table(
|
|
50
|
+
headers: ["Component", "Size", "Events", "Usage", "Status"],
|
|
51
|
+
rows: [[
|
|
52
|
+
"Queue",
|
|
53
|
+
format_size(size_mb),
|
|
54
|
+
format_number(stats[:event_count]),
|
|
55
|
+
"#{percentage}%",
|
|
56
|
+
status
|
|
57
|
+
]]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
output("")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def print_configuration
|
|
64
|
+
retention_days = (SolidObserver.config.event_retention / 1.day).to_i
|
|
65
|
+
max_size_mb = bytes_to_mb(SolidObserver.config.max_db_size)
|
|
66
|
+
|
|
67
|
+
info("Configuration:")
|
|
68
|
+
output(" Retention: #{retention_days} days")
|
|
69
|
+
output(" Max size: #{format_size(max_size_mb)} per database")
|
|
70
|
+
output(" Warning: #{(SolidObserver.config.warning_threshold * 100).to_i}% threshold")
|
|
71
|
+
output("")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def bytes_to_mb(bytes)
|
|
75
|
+
(bytes / 1_048_576.0).round(2)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def calculate_percentage(current, max)
|
|
79
|
+
return 0.0 if max.zero?
|
|
80
|
+
|
|
81
|
+
((current.to_f / max) * 100).round(2)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def status_indicator(percentage)
|
|
85
|
+
threshold = SolidObserver.config.warning_threshold * 100
|
|
86
|
+
|
|
87
|
+
if percentage >= threshold
|
|
88
|
+
"⚠️ Warning"
|
|
89
|
+
else
|
|
90
|
+
"✓ OK"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def format_size(size_mb)
|
|
95
|
+
if size_mb >= 1024
|
|
96
|
+
"#{(size_mb / 1024.0).round(2)} GB"
|
|
97
|
+
else
|
|
98
|
+
"#{size_mb} MB"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def format_number(number)
|
|
103
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def print_section_header(title)
|
|
107
|
+
output("")
|
|
108
|
+
output(title, color: :cyan)
|
|
109
|
+
output("=" * 50, color: :cyan)
|
|
110
|
+
output("")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|