solid_ops 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/.DS_Store +0 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +308 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/solid_ops/application.css +1 -0
- data/app/controllers/solid_ops/application_controller.rb +127 -0
- data/app/controllers/solid_ops/cache_entries_controller.rb +38 -0
- data/app/controllers/solid_ops/channels_controller.rb +30 -0
- data/app/controllers/solid_ops/dashboard_controller.rb +80 -0
- data/app/controllers/solid_ops/events_controller.rb +37 -0
- data/app/controllers/solid_ops/jobs_controller.rb +64 -0
- data/app/controllers/solid_ops/processes_controller.rb +11 -0
- data/app/controllers/solid_ops/queues_controller.rb +75 -0
- data/app/controllers/solid_ops/recurring_tasks_controller.rb +11 -0
- data/app/helpers/solid_ops/application_helper.rb +112 -0
- data/app/jobs/solid_ops/purge_job.rb +16 -0
- data/app/models/solid_ops/event.rb +34 -0
- data/app/views/layouts/solid_ops/application.html.erb +118 -0
- data/app/views/solid_ops/cache_entries/index.html.erb +86 -0
- data/app/views/solid_ops/cache_entries/show.html.erb +153 -0
- data/app/views/solid_ops/channels/index.html.erb +81 -0
- data/app/views/solid_ops/channels/show.html.erb +66 -0
- data/app/views/solid_ops/dashboard/cable.html.erb +98 -0
- data/app/views/solid_ops/dashboard/cache.html.erb +104 -0
- data/app/views/solid_ops/dashboard/index.html.erb +169 -0
- data/app/views/solid_ops/dashboard/jobs.html.erb +108 -0
- data/app/views/solid_ops/events/index.html.erb +98 -0
- data/app/views/solid_ops/events/show.html.erb +108 -0
- data/app/views/solid_ops/jobs/failed.html.erb +89 -0
- data/app/views/solid_ops/jobs/running.html.erb +134 -0
- data/app/views/solid_ops/jobs/show.html.erb +116 -0
- data/app/views/solid_ops/processes/index.html.erb +69 -0
- data/app/views/solid_ops/queues/index.html.erb +182 -0
- data/app/views/solid_ops/queues/show.html.erb +121 -0
- data/app/views/solid_ops/recurring_tasks/index.html.erb +64 -0
- data/app/views/solid_ops/shared/_nav.html.erb +50 -0
- data/app/views/solid_ops/shared/_pagination.html.erb +31 -0
- data/app/views/solid_ops/shared/_time_window.html.erb +10 -0
- data/app/views/solid_ops/shared/component_unavailable.html.erb +63 -0
- data/config/routes.rb +49 -0
- data/db/migrate/20260224000100_create_solid_ops_events.rb +31 -0
- data/lib/generators/solid_ops/install/install_generator.rb +348 -0
- data/lib/generators/solid_ops/install/templates/create_solid_ops_events.rb +31 -0
- data/lib/generators/solid_ops/install/templates/solid_ops_initializer.rb +31 -0
- data/lib/solid_ops/configuration.rb +28 -0
- data/lib/solid_ops/context.rb +34 -0
- data/lib/solid_ops/current.rb +10 -0
- data/lib/solid_ops/engine.rb +60 -0
- data/lib/solid_ops/job_extension.rb +50 -0
- data/lib/solid_ops/middleware.rb +52 -0
- data/lib/solid_ops/subscribers.rb +215 -0
- data/lib/solid_ops/version.rb +5 -0
- data/lib/solid_ops.rb +25 -0
- data/lib/tasks/solid_ops.rake +32 -0
- data/log/test.log +2 -0
- data/sig/solid_ops.rbs +4 -0
- metadata +119 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidOps
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@window = time_window
|
|
7
|
+
base = SolidOps::Event.where("occurred_at >= ?", @window)
|
|
8
|
+
|
|
9
|
+
@total_events = base.count
|
|
10
|
+
@stats = base.group(:event_type)
|
|
11
|
+
.select("event_type, COUNT(*) as event_count, AVG(duration_ms) as avg_duration, MAX(duration_ms) as max_duration")
|
|
12
|
+
.index_by(&:event_type)
|
|
13
|
+
|
|
14
|
+
@recent_events = base.recent.limit(10)
|
|
15
|
+
@unique_correlations = base.where.not(correlation_id: nil).distinct.count(:correlation_id)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def jobs
|
|
19
|
+
@window = time_window
|
|
20
|
+
base = SolidOps::Event.job_events.where("occurred_at >= ?", @window).limit(100_000)
|
|
21
|
+
|
|
22
|
+
@enqueued = base.where(event_type: "job.enqueue")
|
|
23
|
+
@performed = base.where(event_type: "job.perform")
|
|
24
|
+
@enqueue_count = @enqueued.count
|
|
25
|
+
@perform_count = @performed.count
|
|
26
|
+
@avg_perform_ms = @performed.average(:duration_ms)
|
|
27
|
+
@max_perform_ms = @performed.maximum(:duration_ms)
|
|
28
|
+
@error_count = @performed.where("metadata LIKE ?", '%"exception"%').where.not("metadata LIKE ?", '%"exception":null%').count
|
|
29
|
+
@top_jobs = base.where(event_type: "job.perform")
|
|
30
|
+
.group(:name)
|
|
31
|
+
.select("name, COUNT(*) as event_count, AVG(duration_ms) as avg_duration")
|
|
32
|
+
.order("event_count DESC")
|
|
33
|
+
.limit(20)
|
|
34
|
+
@recent = base.recent.limit(50)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cache
|
|
38
|
+
@window = time_window
|
|
39
|
+
base = SolidOps::Event.cache_events.where("occurred_at >= ?", @window)
|
|
40
|
+
|
|
41
|
+
@reads = base.where(event_type: "cache.read")
|
|
42
|
+
@writes = base.where(event_type: "cache.write")
|
|
43
|
+
@deletes = base.where(event_type: "cache.delete")
|
|
44
|
+
@read_count = @reads.count
|
|
45
|
+
@write_count = @writes.count
|
|
46
|
+
@delete_count = @deletes.count
|
|
47
|
+
@hit_count = @reads.where("metadata LIKE ?", '%"hit":true%').count
|
|
48
|
+
@miss_count = @read_count - @hit_count
|
|
49
|
+
@hit_rate = @read_count.positive? ? (@hit_count.to_f / @read_count * 100).round(1) : nil
|
|
50
|
+
@top_keys = base.group(:name)
|
|
51
|
+
.select("name, COUNT(*) as event_count")
|
|
52
|
+
.order("event_count DESC")
|
|
53
|
+
.limit(20)
|
|
54
|
+
@recent = base.recent.limit(50)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cable
|
|
58
|
+
@window = time_window
|
|
59
|
+
base = SolidOps::Event.cable_events.where("occurred_at >= ?", @window)
|
|
60
|
+
|
|
61
|
+
@broadcast_count = base.count
|
|
62
|
+
@avg_duration = base.average(:duration_ms)
|
|
63
|
+
@streams = base.group(:name)
|
|
64
|
+
.select("name, COUNT(*) as event_count, AVG(duration_ms) as avg_duration")
|
|
65
|
+
.order("event_count DESC")
|
|
66
|
+
.limit(20)
|
|
67
|
+
@recent = base.recent.limit(50)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def time_window
|
|
73
|
+
windows = {
|
|
74
|
+
"5m" => 5.minutes.ago, "15m" => 15.minutes.ago, "30m" => 30.minutes.ago,
|
|
75
|
+
"1h" => 1.hour.ago, "6h" => 6.hours.ago, "24h" => 24.hours.ago, "7d" => 7.days.ago
|
|
76
|
+
}
|
|
77
|
+
windows[params[:window]] || 1.hour.ago
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidOps
|
|
4
|
+
class EventsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
scope = SolidOps::Event.all
|
|
7
|
+
scope = scope.for_type(params[:event_type])
|
|
8
|
+
scope = scope.for_correlation(params[:correlation_id])
|
|
9
|
+
scope = scope.for_request(params[:request_id])
|
|
10
|
+
scope = scope.for_tenant(params[:tenant_id])
|
|
11
|
+
scope = scope.for_actor(params[:actor_id])
|
|
12
|
+
scope = scope.search_name(params[:q])
|
|
13
|
+
scope = scope.since(parse_time(params[:since]))
|
|
14
|
+
scope = scope.before(parse_time(params[:before]))
|
|
15
|
+
|
|
16
|
+
@events = paginate(scope.recent)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show
|
|
20
|
+
@event = SolidOps::Event.find(params[:id])
|
|
21
|
+
@related = SolidOps::Event
|
|
22
|
+
.where(correlation_id: @event.correlation_id)
|
|
23
|
+
.chronological
|
|
24
|
+
.limit(200)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def parse_time(val)
|
|
30
|
+
return nil if val.blank?
|
|
31
|
+
|
|
32
|
+
Time.zone.parse(val)
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidOps
|
|
4
|
+
class JobsController < ApplicationController
|
|
5
|
+
before_action :require_solid_queue!
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@job = SolidQueue::Job.find(params[:id])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def running
|
|
12
|
+
base = SolidQueue::Job
|
|
13
|
+
.joins(:claimed_execution)
|
|
14
|
+
.includes(:claimed_execution)
|
|
15
|
+
.order("solid_queue_claimed_executions.created_at ASC")
|
|
16
|
+
@running_count = base.count
|
|
17
|
+
@running_jobs = base.limit(500)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def failed
|
|
21
|
+
scope = SolidQueue::Job
|
|
22
|
+
.joins(:failed_execution)
|
|
23
|
+
.includes(:failed_execution)
|
|
24
|
+
.order("solid_queue_failed_executions.created_at DESC")
|
|
25
|
+
@failed_jobs = paginate(scope)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def retry
|
|
29
|
+
job = SolidQueue::Job.find(params[:id])
|
|
30
|
+
job.retry
|
|
31
|
+
redirect_back fallback_location: failed_jobs_path, notice: "Job #{job.id} queued for retry."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def discard
|
|
35
|
+
job = SolidQueue::Job.find(params[:id])
|
|
36
|
+
job.failed_execution&.discard
|
|
37
|
+
redirect_back fallback_location: failed_jobs_path, notice: "Job #{job.id} discarded."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def retry_all
|
|
41
|
+
count = SolidQueue::FailedExecution.count
|
|
42
|
+
SolidQueue::FailedExecution.find_each(batch_size: 100, &:retry)
|
|
43
|
+
redirect_to failed_jobs_path, notice: "#{count} failed jobs queued for retry."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def discard_all
|
|
47
|
+
count = SolidQueue::FailedExecution.count
|
|
48
|
+
SolidQueue::FailedExecution.discard_all_in_batches
|
|
49
|
+
redirect_to failed_jobs_path, notice: "#{count} failed jobs discarded."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def clear_finished
|
|
53
|
+
count = SolidQueue::Job.where.not(finished_at: nil).count
|
|
54
|
+
SolidQueue::Job.clear_finished_in_batches
|
|
55
|
+
redirect_to queues_path, notice: "#{count} finished jobs cleared."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def destroy
|
|
59
|
+
job = SolidQueue::Job.find(params[:id])
|
|
60
|
+
job.destroy
|
|
61
|
+
redirect_to queues_path, notice: "Job #{params[:id]} deleted."
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidOps
|
|
4
|
+
class QueuesController < ApplicationController
|
|
5
|
+
before_action :require_solid_queue!
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@queues = queue_stats
|
|
9
|
+
@paused_queues = SolidQueue::Pause.all.map(&:queue_name)
|
|
10
|
+
@total_jobs = SolidQueue::Job.count
|
|
11
|
+
@ready_count = SolidQueue::ReadyExecution.count
|
|
12
|
+
@scheduled_count = SolidQueue::ScheduledExecution.count
|
|
13
|
+
@claimed_count = SolidQueue::ClaimedExecution.count
|
|
14
|
+
@failed_count = SolidQueue::FailedExecution.count
|
|
15
|
+
@blocked_count = SolidQueue::BlockedExecution.count
|
|
16
|
+
@finished_count = SolidQueue::Job.where.not(finished_at: nil).count
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show
|
|
20
|
+
@queue_name = params[:id]
|
|
21
|
+
@paused = SolidQueue::Pause.exists?(queue_name: @queue_name)
|
|
22
|
+
@jobs = paginate(SolidQueue::Job.where(queue_name: @queue_name).order(created_at: :desc))
|
|
23
|
+
@ready_count = SolidQueue::ReadyExecution.where(queue_name: @queue_name).count
|
|
24
|
+
@scheduled_count = SolidQueue::ScheduledExecution.joins(:job).where(solid_queue_jobs: { queue_name: @queue_name }).count
|
|
25
|
+
@claimed_count = SolidQueue::ClaimedExecution.joins(:job).where(solid_queue_jobs: { queue_name: @queue_name }).count
|
|
26
|
+
@failed_count = SolidQueue::FailedExecution.joins(:job).where(solid_queue_jobs: { queue_name: @queue_name }).count
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pause
|
|
30
|
+
SolidQueue::Pause.create!(queue_name: params[:id])
|
|
31
|
+
redirect_to queues_path, notice: "Queue '#{params[:id]}' paused."
|
|
32
|
+
rescue ActiveRecord::RecordNotUnique
|
|
33
|
+
redirect_to queues_path, notice: "Queue '#{params[:id]}' is already paused."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resume
|
|
37
|
+
SolidQueue::Pause.where(queue_name: params[:id]).delete_all
|
|
38
|
+
redirect_to queues_path, notice: "Queue '#{params[:id]}' resumed."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def queue_stats
|
|
44
|
+
queues = SolidQueue::Job.group(:queue_name)
|
|
45
|
+
.select("queue_name, COUNT(*) as total_count")
|
|
46
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
47
|
+
.map { |q| { name: q.queue_name, total: q.total_count } }
|
|
48
|
+
|
|
49
|
+
paused = SolidQueue::Pause.pluck(:queue_name)
|
|
50
|
+
|
|
51
|
+
# Batch count queries to avoid N+1
|
|
52
|
+
queue_names = queues.map { |q| q[:name] }
|
|
53
|
+
ready_counts = SolidQueue::ReadyExecution.where(queue_name: queue_names).group(:queue_name).count
|
|
54
|
+
failed_counts = SolidQueue::FailedExecution.joins(:job)
|
|
55
|
+
.where(solid_queue_jobs: { queue_name: queue_names })
|
|
56
|
+
.group("solid_queue_jobs.queue_name").count
|
|
57
|
+
scheduled_counts = SolidQueue::ScheduledExecution.joins(:job)
|
|
58
|
+
.where(solid_queue_jobs: { queue_name: queue_names })
|
|
59
|
+
.group("solid_queue_jobs.queue_name").count
|
|
60
|
+
claimed_counts = SolidQueue::ClaimedExecution.joins(:job)
|
|
61
|
+
.where(solid_queue_jobs: { queue_name: queue_names })
|
|
62
|
+
.group("solid_queue_jobs.queue_name").count
|
|
63
|
+
|
|
64
|
+
queues.each do |q|
|
|
65
|
+
q[:ready] = ready_counts[q[:name]] || 0
|
|
66
|
+
q[:failed] = failed_counts[q[:name]] || 0
|
|
67
|
+
q[:scheduled] = scheduled_counts[q[:name]] || 0
|
|
68
|
+
q[:claimed] = claimed_counts[q[:name]] || 0
|
|
69
|
+
q[:paused] = paused.include?(q[:name])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
queues
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidOps
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
def event_pill_class(event_type)
|
|
6
|
+
base = "inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold ring-1 ring-inset"
|
|
7
|
+
case event_type.to_s
|
|
8
|
+
when /^cable\./ then "#{base} bg-purple-50 text-purple-700 ring-purple-600/20"
|
|
9
|
+
when /^job\./ then "#{base} bg-blue-50 text-blue-700 ring-blue-700/10"
|
|
10
|
+
when /^cache\./ then "#{base} bg-emerald-50 text-emerald-700 ring-emerald-600/20"
|
|
11
|
+
else "#{base} bg-gray-50 text-gray-600 ring-gray-500/10"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def status_pill(status)
|
|
16
|
+
base = "inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold ring-1 ring-inset"
|
|
17
|
+
colors = {
|
|
18
|
+
"ready" => "bg-yellow-50 text-yellow-800 ring-yellow-600/20",
|
|
19
|
+
"claimed" => "bg-blue-50 text-blue-700 ring-blue-700/10",
|
|
20
|
+
"failed" => "bg-red-50 text-red-700 ring-red-600/10",
|
|
21
|
+
"blocked" => "bg-orange-50 text-orange-700 ring-orange-600/20",
|
|
22
|
+
"scheduled" => "bg-indigo-50 text-indigo-700 ring-indigo-700/10",
|
|
23
|
+
"finished" => "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
|
|
24
|
+
"paused" => "bg-gray-50 text-gray-600 ring-gray-500/10"
|
|
25
|
+
}
|
|
26
|
+
"#{base} #{colors.fetch(status.to_s, "bg-gray-50 text-gray-600 ring-gray-500/10")}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_duration(ms)
|
|
30
|
+
return "—" unless ms
|
|
31
|
+
|
|
32
|
+
if ms < 1
|
|
33
|
+
format("%.3fms", ms)
|
|
34
|
+
elsif ms < 1000
|
|
35
|
+
format("%.1fms", ms)
|
|
36
|
+
else
|
|
37
|
+
format("%.2fs", ms / 1000.0)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def format_time(t)
|
|
42
|
+
t&.strftime("%H:%M:%S.%L")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_datetime(t)
|
|
46
|
+
t&.strftime("%Y-%m-%d %H:%M:%S")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def format_bytes(bytes)
|
|
50
|
+
return "—" unless bytes
|
|
51
|
+
|
|
52
|
+
if bytes < 1024
|
|
53
|
+
"#{bytes} B"
|
|
54
|
+
elsif bytes < 1024 * 1024
|
|
55
|
+
format("%.1f KB", bytes / 1024.0)
|
|
56
|
+
elsif bytes < 1024 * 1024 * 1024
|
|
57
|
+
format("%.1f MB", bytes / (1024.0 * 1024))
|
|
58
|
+
else
|
|
59
|
+
format("%.2f GB", bytes / (1024.0 * 1024 * 1024))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def solid_component_available?(component)
|
|
64
|
+
case component
|
|
65
|
+
when :queue then defined?(SolidQueue)
|
|
66
|
+
when :cache then defined?(SolidCache)
|
|
67
|
+
when :cable then defined?(SolidCable)
|
|
68
|
+
else false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def time_ago_short(time)
|
|
73
|
+
return "—" unless time
|
|
74
|
+
|
|
75
|
+
time = Time.zone.parse(time) if time.is_a?(String)
|
|
76
|
+
seconds = (Time.current - time).to_i
|
|
77
|
+
case seconds
|
|
78
|
+
when 0..59 then "#{seconds}s ago"
|
|
79
|
+
when 60..3599 then "#{seconds / 60}m ago"
|
|
80
|
+
when 3600..86_399 then "#{seconds / 3600}h ago"
|
|
81
|
+
else "#{seconds / 86_400}d ago"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def duration_since(time)
|
|
86
|
+
return "—" unless time
|
|
87
|
+
|
|
88
|
+
seconds = (Time.current - time).to_i
|
|
89
|
+
if seconds < 60
|
|
90
|
+
"#{seconds}s"
|
|
91
|
+
elsif seconds < 3600
|
|
92
|
+
"#{seconds / 60}m #{seconds % 60}s"
|
|
93
|
+
elsif seconds < 86_400
|
|
94
|
+
"#{seconds / 3600}h #{(seconds % 3600) / 60}m"
|
|
95
|
+
else
|
|
96
|
+
"#{seconds / 86_400}d #{(seconds % 86_400) / 3600}h"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Pagination page-number list with ellipsis gaps
|
|
101
|
+
def pages_to_show(current, total)
|
|
102
|
+
return (1..total).to_a if total <= 7
|
|
103
|
+
|
|
104
|
+
pages = [1]
|
|
105
|
+
pages << :gap if current > 3
|
|
106
|
+
((current - 1)..(current + 1)).each { |p| pages << p if p > 1 && p < total }
|
|
107
|
+
pages << :gap if current < total - 2
|
|
108
|
+
pages << total
|
|
109
|
+
pages.uniq
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidOps
|
|
4
|
+
class PurgeJob < ActiveJob::Base
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform
|
|
8
|
+
retention = SolidOps.configuration.retention_period
|
|
9
|
+
return unless retention
|
|
10
|
+
|
|
11
|
+
cutoff = retention.ago
|
|
12
|
+
deleted = SolidOps::Event.purge!(before: cutoff)
|
|
13
|
+
Rails.logger.info { "[SolidOps] Purged #{deleted} events older than #{cutoff}" }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidOps
|
|
4
|
+
class Event < ActiveRecord::Base
|
|
5
|
+
self.table_name = "solid_ops_events"
|
|
6
|
+
|
|
7
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
8
|
+
scope :chronological, -> { order(occurred_at: :asc) }
|
|
9
|
+
scope :for_type, ->(t) { t.present? ? where(event_type: t) : all }
|
|
10
|
+
scope :for_correlation, ->(cid) { cid.present? ? where(correlation_id: cid) : all }
|
|
11
|
+
scope :for_request, ->(rid) { rid.present? ? where(request_id: rid) : all }
|
|
12
|
+
scope :for_tenant, ->(tid) { tid.present? ? where(tenant_id: tid) : all }
|
|
13
|
+
scope :for_actor, ->(aid) { aid.present? ? where(actor_id: aid) : all }
|
|
14
|
+
scope :search_name, ->(q) { q.present? ? where("name LIKE ?", "%#{sanitize_sql_like(q)}%") : all }
|
|
15
|
+
scope :since, ->(t) { t.present? ? where("occurred_at >= ?", t) : all }
|
|
16
|
+
scope :before, ->(t) { t.present? ? where("occurred_at <= ?", t) : all }
|
|
17
|
+
scope :older_than, ->(t) { where("occurred_at < ?", t) }
|
|
18
|
+
|
|
19
|
+
# Component scopes
|
|
20
|
+
scope :cable_events, -> { where("event_type LIKE ?", "cable.%") }
|
|
21
|
+
scope :job_events, -> { where("event_type LIKE ?", "job.%") }
|
|
22
|
+
scope :cache_events, -> { where("event_type LIKE ?", "cache.%") }
|
|
23
|
+
|
|
24
|
+
def self.purge!(before:)
|
|
25
|
+
older_than(before).delete_all
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.stats_since(since = 1.hour.ago)
|
|
29
|
+
where("occurred_at >= ?", since)
|
|
30
|
+
.group(:event_type)
|
|
31
|
+
.select("event_type, COUNT(*) as event_count, AVG(duration_ms) as avg_duration")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<% # Shared layout wrapper for all SolidOps pages %>
|
|
2
|
+
<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<title>SolidOps<%= " – #{content_for(:title)}" if content_for?(:title) %></title>
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
11
|
+
<%= stylesheet_link_tag "solid_ops/application", media: "all" %>
|
|
12
|
+
<%= csrf_meta_tags %>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div class="solid-ops">
|
|
16
|
+
|
|
17
|
+
<%= render "solid_ops/shared/nav" %>
|
|
18
|
+
|
|
19
|
+
<main class="min-h-[calc(100vh-8rem)]">
|
|
20
|
+
<% if flash[:notice] %>
|
|
21
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4 animate-slide-in">
|
|
22
|
+
<div class="flex items-center gap-3 rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-emerald-800 text-sm shadow-sm">
|
|
23
|
+
<svg class="w-5 h-5 text-emerald-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
|
24
|
+
<%= flash[:notice] %>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% if flash[:alert] %>
|
|
29
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4 animate-slide-in">
|
|
30
|
+
<div class="flex items-center gap-3 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-red-800 text-sm shadow-sm">
|
|
31
|
+
<svg class="w-5 h-5 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>
|
|
32
|
+
<%= flash[:alert] %>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<%= yield %>
|
|
38
|
+
</main>
|
|
39
|
+
|
|
40
|
+
<footer class="border-t border-gray-200 mt-12 bg-white">
|
|
41
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex items-center justify-between">
|
|
42
|
+
<div class="flex items-center gap-2 text-xs text-gray-400">
|
|
43
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>
|
|
44
|
+
<span>SolidOps — Rails-native observability for the Solid Trifecta</span>
|
|
45
|
+
</div>
|
|
46
|
+
<span class="text-[10px] text-gray-300 font-mono">v<%= SolidOps::VERSION %></span>
|
|
47
|
+
</div>
|
|
48
|
+
</footer>
|
|
49
|
+
</div><!-- /.solid-ops -->
|
|
50
|
+
<script>
|
|
51
|
+
(function(){
|
|
52
|
+
// ── Client-side table search ──
|
|
53
|
+
document.querySelectorAll('[data-solid-search]').forEach(function(input){
|
|
54
|
+
var tid = input.dataset.solidSearch;
|
|
55
|
+
var table = document.getElementById(tid);
|
|
56
|
+
if(!table) return;
|
|
57
|
+
input.addEventListener('input', function(){
|
|
58
|
+
var term = this.value.toLowerCase();
|
|
59
|
+
var rows = table.querySelectorAll('tbody tr');
|
|
60
|
+
var count = 0;
|
|
61
|
+
rows.forEach(function(row){
|
|
62
|
+
var match = !term || row.textContent.toLowerCase().indexOf(term) !== -1;
|
|
63
|
+
row.style.display = match ? '' : 'none';
|
|
64
|
+
if(match) count++;
|
|
65
|
+
});
|
|
66
|
+
var el = document.querySelector('[data-solid-count="'+tid+'"]');
|
|
67
|
+
if(el) el.textContent = count;
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── Client-side column sorting ──
|
|
72
|
+
document.querySelectorAll('th[data-sort]').forEach(function(th){
|
|
73
|
+
th.style.cursor = 'pointer';
|
|
74
|
+
th.style.userSelect = 'none';
|
|
75
|
+
var arrow = document.createElement('span');
|
|
76
|
+
arrow.className = 'so-sort';
|
|
77
|
+
arrow.textContent = ' \u21C5';
|
|
78
|
+
arrow.style.opacity = '0.3';
|
|
79
|
+
arrow.style.fontSize = '10px';
|
|
80
|
+
th.appendChild(arrow);
|
|
81
|
+
|
|
82
|
+
th.addEventListener('click', function(){
|
|
83
|
+
var table = th.closest('table');
|
|
84
|
+
var tbody = table.querySelector('tbody');
|
|
85
|
+
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
86
|
+
var idx = Array.from(th.parentNode.children).indexOf(th);
|
|
87
|
+
var type = th.dataset.sort;
|
|
88
|
+
var asc = th.dataset.dir !== 'asc';
|
|
89
|
+
|
|
90
|
+
th.parentNode.querySelectorAll('th[data-sort]').forEach(function(h){
|
|
91
|
+
h.dataset.dir = '';
|
|
92
|
+
var a = h.querySelector('.so-sort');
|
|
93
|
+
if(a){ a.textContent = ' \u21C5'; a.style.opacity = '0.3'; }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
th.dataset.dir = asc ? 'asc' : 'desc';
|
|
97
|
+
arrow.textContent = asc ? ' \u2191' : ' \u2193';
|
|
98
|
+
arrow.style.opacity = '0.7';
|
|
99
|
+
|
|
100
|
+
rows.sort(function(a,b){
|
|
101
|
+
var av = a.children[idx] ? a.children[idx].textContent.trim() : '';
|
|
102
|
+
var bv = b.children[idx] ? b.children[idx].textContent.trim() : '';
|
|
103
|
+
if(type === 'num'){
|
|
104
|
+
av = parseFloat(av.replace(/[^0-9.\-]/g,'')) || 0;
|
|
105
|
+
bv = parseFloat(bv.replace(/[^0-9.\-]/g,'')) || 0;
|
|
106
|
+
} else {
|
|
107
|
+
av = av.toLowerCase();
|
|
108
|
+
bv = bv.toLowerCase();
|
|
109
|
+
}
|
|
110
|
+
return av < bv ? (asc?-1:1) : av > bv ? (asc?1:-1) : 0;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
rows.forEach(function(r){ tbody.appendChild(r); });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
})();
|
|
117
|
+
</script>
|
|
118
|
+
</body></html>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
|
|
2
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 animate-fade-in">
|
|
3
|
+
<div class="flex items-center justify-between mb-8">
|
|
4
|
+
<div>
|
|
5
|
+
<h1 class="text-2xl font-extrabold text-gray-900 tracking-tight">Cache Management</h1>
|
|
6
|
+
<p class="text-sm text-gray-500 mt-1">Solid Cache — browse and manage cache entries</p>
|
|
7
|
+
</div>
|
|
8
|
+
<% if @total_entries > 0 %>
|
|
9
|
+
<%= form_tag(solid_ops.clear_all_cache_entries_path, method: :post, class: "inline") do %>
|
|
10
|
+
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-red-700 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 transition-colors ring-1 ring-inset ring-red-600/10"
|
|
11
|
+
onclick="return confirm('Clear ALL <%= @total_entries %> cache entries? This cannot be undone.')">
|
|
12
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
13
|
+
Clear All Cache
|
|
14
|
+
</button>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-8">
|
|
20
|
+
<div class="bg-white rounded-xl border border-gray-200 p-5 shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
|
|
21
|
+
<div class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-400 to-emerald-600"></div>
|
|
22
|
+
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Entries</p>
|
|
23
|
+
<p class="text-3xl font-extrabold font-mono mt-2"><%= number_with_delimiter(@total_entries) %></p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="bg-white rounded-xl border border-gray-200 p-5 shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
|
|
26
|
+
<div class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-400 to-blue-600"></div>
|
|
27
|
+
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Storage Used</p>
|
|
28
|
+
<p class="text-3xl font-extrabold font-mono mt-2 text-blue-600"><%= format_bytes(@total_bytes) %></p>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="bg-white rounded-xl border border-gray-200 p-5 shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
|
|
31
|
+
<div class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-400 to-amber-600"></div>
|
|
32
|
+
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Avg Entry Size</p>
|
|
33
|
+
<p class="text-3xl font-extrabold font-mono mt-2 text-emerald-600"><%= @total_entries > 0 ? format_bytes(@total_bytes / @total_entries) : "—" %></p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- Entries table -->
|
|
38
|
+
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
|
39
|
+
<% if @entries.any? %>
|
|
40
|
+
<div style="padding: 0.625rem 1rem; border-bottom: 1px solid rgb(243 244 246);">
|
|
41
|
+
<div style="position: relative; display: inline-block;">
|
|
42
|
+
<svg style="position: absolute; left: 0.625rem; top: 50%; transform: translateY(-50%); width: 1rem; height: 1rem; color: #9ca3af; pointer-events: none;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
|
43
|
+
<input type="text" data-solid-search="cache-table" placeholder="Filter cache keys…" style="padding: 0.375rem 0.75rem 0.375rem 2rem; font-size: 0.875rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; width: 18rem; background: rgba(249,250,251,0.5);">
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<table id="cache-table" class="min-w-full divide-y divide-gray-100">
|
|
47
|
+
<thead>
|
|
48
|
+
<tr class="bg-gray-50">
|
|
49
|
+
<th class="px-6 py-3 text-left text-[11px] font-bold text-gray-500 uppercase tracking-wider" data-sort="text">Key</th>
|
|
50
|
+
<th class="px-6 py-3 text-left text-[11px] font-bold text-gray-500 uppercase tracking-wider" data-sort="num">Size</th>
|
|
51
|
+
<th class="px-6 py-3 text-left text-[11px] font-bold text-gray-500 uppercase tracking-wider" data-sort="text">Created</th>
|
|
52
|
+
<th class="px-6 py-3 text-right text-[11px] font-bold text-gray-500 uppercase tracking-wider">Actions</th>
|
|
53
|
+
</tr>
|
|
54
|
+
</thead>
|
|
55
|
+
<tbody class="divide-y divide-gray-50">
|
|
56
|
+
<% @entries.each do |entry| %>
|
|
57
|
+
<tr class="hover:bg-gray-50/80 transition-colors group" style="cursor: pointer;" onclick="window.location='<%= solid_ops.cache_entry_path(entry.id) %>'">
|
|
58
|
+
<td class="px-6 py-3.5 font-mono text-xs text-blue-600 max-w-md truncate"><%= entry.key %></td>
|
|
59
|
+
<td class="px-6 py-3.5 font-mono text-xs text-gray-500"><%= format_bytes(entry.byte_size) %></td>
|
|
60
|
+
<td class="px-6 py-3.5 font-mono text-xs text-gray-500"><%= time_ago_short(entry.created_at) %></td>
|
|
61
|
+
<td class="px-6 py-3.5 text-right" onclick="event.stopPropagation()">
|
|
62
|
+
<%= form_tag(solid_ops.cache_entry_path(entry.id), method: :delete, class: "inline") do %>
|
|
63
|
+
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-red-700 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 transition-colors ring-1 ring-inset ring-red-600/10"
|
|
64
|
+
onclick="if(!confirm('Delete this cache entry?')){event.preventDefault();}">
|
|
65
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
66
|
+
Delete
|
|
67
|
+
</button>
|
|
68
|
+
<% end %>
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
<% end %>
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
<%= render "solid_ops/shared/pagination", current_page: @current_page, total_pages: @total_pages, total_count: @total_count %>
|
|
75
|
+
<% else %>
|
|
76
|
+
<div class="py-20 text-center">
|
|
77
|
+
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gray-100 mb-4">
|
|
78
|
+
<svg class="h-7 w-7 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
|
|
79
|
+
</div>
|
|
80
|
+
<p class="text-sm text-gray-500 font-medium">Cache is empty</p>
|
|
81
|
+
<p class="text-xs text-gray-400 mt-1">Cache entries will appear here when your app writes to the cache</p>
|
|
82
|
+
</div>
|
|
83
|
+
<% end %>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|