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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CODE_OF_CONDUCT.md +10 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +308 -0
  7. data/Rakefile +12 -0
  8. data/app/assets/stylesheets/solid_ops/application.css +1 -0
  9. data/app/controllers/solid_ops/application_controller.rb +127 -0
  10. data/app/controllers/solid_ops/cache_entries_controller.rb +38 -0
  11. data/app/controllers/solid_ops/channels_controller.rb +30 -0
  12. data/app/controllers/solid_ops/dashboard_controller.rb +80 -0
  13. data/app/controllers/solid_ops/events_controller.rb +37 -0
  14. data/app/controllers/solid_ops/jobs_controller.rb +64 -0
  15. data/app/controllers/solid_ops/processes_controller.rb +11 -0
  16. data/app/controllers/solid_ops/queues_controller.rb +75 -0
  17. data/app/controllers/solid_ops/recurring_tasks_controller.rb +11 -0
  18. data/app/helpers/solid_ops/application_helper.rb +112 -0
  19. data/app/jobs/solid_ops/purge_job.rb +16 -0
  20. data/app/models/solid_ops/event.rb +34 -0
  21. data/app/views/layouts/solid_ops/application.html.erb +118 -0
  22. data/app/views/solid_ops/cache_entries/index.html.erb +86 -0
  23. data/app/views/solid_ops/cache_entries/show.html.erb +153 -0
  24. data/app/views/solid_ops/channels/index.html.erb +81 -0
  25. data/app/views/solid_ops/channels/show.html.erb +66 -0
  26. data/app/views/solid_ops/dashboard/cable.html.erb +98 -0
  27. data/app/views/solid_ops/dashboard/cache.html.erb +104 -0
  28. data/app/views/solid_ops/dashboard/index.html.erb +169 -0
  29. data/app/views/solid_ops/dashboard/jobs.html.erb +108 -0
  30. data/app/views/solid_ops/events/index.html.erb +98 -0
  31. data/app/views/solid_ops/events/show.html.erb +108 -0
  32. data/app/views/solid_ops/jobs/failed.html.erb +89 -0
  33. data/app/views/solid_ops/jobs/running.html.erb +134 -0
  34. data/app/views/solid_ops/jobs/show.html.erb +116 -0
  35. data/app/views/solid_ops/processes/index.html.erb +69 -0
  36. data/app/views/solid_ops/queues/index.html.erb +182 -0
  37. data/app/views/solid_ops/queues/show.html.erb +121 -0
  38. data/app/views/solid_ops/recurring_tasks/index.html.erb +64 -0
  39. data/app/views/solid_ops/shared/_nav.html.erb +50 -0
  40. data/app/views/solid_ops/shared/_pagination.html.erb +31 -0
  41. data/app/views/solid_ops/shared/_time_window.html.erb +10 -0
  42. data/app/views/solid_ops/shared/component_unavailable.html.erb +63 -0
  43. data/config/routes.rb +49 -0
  44. data/db/migrate/20260224000100_create_solid_ops_events.rb +31 -0
  45. data/lib/generators/solid_ops/install/install_generator.rb +348 -0
  46. data/lib/generators/solid_ops/install/templates/create_solid_ops_events.rb +31 -0
  47. data/lib/generators/solid_ops/install/templates/solid_ops_initializer.rb +31 -0
  48. data/lib/solid_ops/configuration.rb +28 -0
  49. data/lib/solid_ops/context.rb +34 -0
  50. data/lib/solid_ops/current.rb +10 -0
  51. data/lib/solid_ops/engine.rb +60 -0
  52. data/lib/solid_ops/job_extension.rb +50 -0
  53. data/lib/solid_ops/middleware.rb +52 -0
  54. data/lib/solid_ops/subscribers.rb +215 -0
  55. data/lib/solid_ops/version.rb +5 -0
  56. data/lib/solid_ops.rb +25 -0
  57. data/lib/tasks/solid_ops.rake +32 -0
  58. data/log/test.log +2 -0
  59. data/sig/solid_ops.rbs +4 -0
  60. 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidOps
4
+ class ProcessesController < ApplicationController
5
+ before_action :require_solid_queue!
6
+
7
+ def index
8
+ @processes = SolidQueue::Process.order(created_at: :desc)
9
+ end
10
+ end
11
+ 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidOps
4
+ class RecurringTasksController < ApplicationController
5
+ before_action :require_solid_queue!
6
+
7
+ def index
8
+ @tasks = SolidQueue::RecurringTask.all.order(:key)
9
+ end
10
+ end
11
+ 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 &mdash; 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 &mdash; 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
+