rails-profiler 0.1.4

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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/builds/profiler-toolbar.js +1191 -0
  3. data/app/assets/builds/profiler.css +2668 -0
  4. data/app/assets/builds/profiler.js +2772 -0
  5. data/app/controllers/profiler/api/ajax_controller.rb +36 -0
  6. data/app/controllers/profiler/api/jobs_controller.rb +39 -0
  7. data/app/controllers/profiler/api/outbound_http_controller.rb +36 -0
  8. data/app/controllers/profiler/api/profiles_controller.rb +60 -0
  9. data/app/controllers/profiler/api/toolbar_controller.rb +44 -0
  10. data/app/controllers/profiler/application_controller.rb +19 -0
  11. data/app/controllers/profiler/assets_controller.rb +29 -0
  12. data/app/controllers/profiler/profiles_controller.rb +107 -0
  13. data/app/views/layouts/profiler/application.html.erb +16 -0
  14. data/app/views/layouts/profiler/embedded.html.erb +34 -0
  15. data/app/views/profiler/profiles/index.html.erb +1 -0
  16. data/app/views/profiler/profiles/show.html.erb +4 -0
  17. data/config/routes.rb +36 -0
  18. data/exe/profiler-mcp +8 -0
  19. data/lib/profiler/collectors/ajax_collector.rb +109 -0
  20. data/lib/profiler/collectors/base_collector.rb +92 -0
  21. data/lib/profiler/collectors/cache_collector.rb +96 -0
  22. data/lib/profiler/collectors/database_collector.rb +113 -0
  23. data/lib/profiler/collectors/dump_collector.rb +98 -0
  24. data/lib/profiler/collectors/flamegraph_collector.rb +182 -0
  25. data/lib/profiler/collectors/http_collector.rb +112 -0
  26. data/lib/profiler/collectors/job_collector.rb +50 -0
  27. data/lib/profiler/collectors/performance_collector.rb +103 -0
  28. data/lib/profiler/collectors/request_collector.rb +80 -0
  29. data/lib/profiler/collectors/view_collector.rb +79 -0
  30. data/lib/profiler/configuration.rb +81 -0
  31. data/lib/profiler/engine.rb +17 -0
  32. data/lib/profiler/instrumentation/active_job_instrumentation.rb +22 -0
  33. data/lib/profiler/instrumentation/net_http_instrumentation.rb +153 -0
  34. data/lib/profiler/instrumentation/sidekiq_middleware.rb +18 -0
  35. data/lib/profiler/job_profiler.rb +118 -0
  36. data/lib/profiler/mcp/resources/n1_patterns.rb +62 -0
  37. data/lib/profiler/mcp/resources/recent_jobs.rb +39 -0
  38. data/lib/profiler/mcp/resources/recent_requests.rb +35 -0
  39. data/lib/profiler/mcp/resources/slow_queries.rb +47 -0
  40. data/lib/profiler/mcp/server.rb +217 -0
  41. data/lib/profiler/mcp/tools/analyze_queries.rb +124 -0
  42. data/lib/profiler/mcp/tools/clear_profiles.rb +22 -0
  43. data/lib/profiler/mcp/tools/get_profile_ajax.rb +66 -0
  44. data/lib/profiler/mcp/tools/get_profile_detail.rb +326 -0
  45. data/lib/profiler/mcp/tools/get_profile_dumps.rb +51 -0
  46. data/lib/profiler/mcp/tools/get_profile_http.rb +104 -0
  47. data/lib/profiler/mcp/tools/query_jobs.rb +60 -0
  48. data/lib/profiler/mcp/tools/query_profiles.rb +66 -0
  49. data/lib/profiler/middleware/cors_middleware.rb +55 -0
  50. data/lib/profiler/middleware/profiler_middleware.rb +151 -0
  51. data/lib/profiler/middleware/toolbar_injector.rb +378 -0
  52. data/lib/profiler/models/profile.rb +182 -0
  53. data/lib/profiler/models/sql_query.rb +48 -0
  54. data/lib/profiler/models/timeline_event.rb +40 -0
  55. data/lib/profiler/railtie.rb +75 -0
  56. data/lib/profiler/storage/base_store.rb +41 -0
  57. data/lib/profiler/storage/blob_store.rb +46 -0
  58. data/lib/profiler/storage/file_store.rb +119 -0
  59. data/lib/profiler/storage/memory_store.rb +94 -0
  60. data/lib/profiler/storage/redis_store.rb +98 -0
  61. data/lib/profiler/storage/sqlite_store.rb +272 -0
  62. data/lib/profiler/tasks/profiler.rake +79 -0
  63. data/lib/profiler/version.rb +5 -0
  64. data/lib/profiler.rb +68 -0
  65. metadata +194 -0
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class AjaxController < ApplicationController
6
+ skip_before_action :verify_authenticity_token, only: [:link]
7
+
8
+ def link
9
+ parent_token = params[:parent_token]
10
+ child_token = params[:child_token]
11
+
12
+ # Validate parameters
13
+ if parent_token.blank? || child_token.blank?
14
+ return render json: { error: "Missing parent_token or child_token" }, status: :bad_request
15
+ end
16
+
17
+ # Load child profile
18
+ child_profile = Profiler.storage.load(child_token)
19
+ unless child_profile
20
+ return render json: { error: "Child profile not found" }, status: :not_found
21
+ end
22
+
23
+ # Set parent relationship
24
+ child_profile.parent_token = parent_token
25
+ child_profile.is_ajax = true
26
+
27
+ # Save updated profile
28
+ Profiler.storage.save(child_token, child_profile)
29
+
30
+ render json: { success: true }
31
+ rescue => e
32
+ render json: { error: e.message }, status: :internal_server_error
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class JobsController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def index
9
+ all_profiles = Profiler.storage.list(limit: (params[:limit] || 200).to_i, offset: (params[:offset] || 0).to_i)
10
+ job_profiles = all_profiles.select { |p| p.profile_type == "job" }
11
+ job_profiles = job_profiles.first((params[:limit] || 50).to_i)
12
+ render json: job_profiles.map(&:to_h)
13
+ end
14
+
15
+ def show
16
+ profile = Profiler.storage.load(params[:id])
17
+
18
+ unless profile && profile.profile_type == "job"
19
+ return render json: { error: "Job profile not found" }, status: :not_found
20
+ end
21
+
22
+ render json: profile.to_h
23
+ end
24
+
25
+ def destroy
26
+ profile = Profiler.storage.load(params[:id])
27
+ return render json: { error: "Job profile not found" }, status: :not_found unless profile&.profile_type == "job"
28
+
29
+ Profiler.storage.delete(params[:id])
30
+ head :no_content
31
+ end
32
+
33
+ def clear
34
+ Profiler.storage.clear(type: "job")
35
+ head :no_content
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class OutboundHttpController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ # GET /_profiler/api/outbound_http
9
+ def index
10
+ limit = (params[:limit] || 200).to_i
11
+ profiles = Profiler.storage.list(limit: limit)
12
+
13
+ requests = profiles.flat_map do |profile|
14
+ http_data = profile.collector_data("http")
15
+ next [] unless http_data && http_data["requests"]&.any?
16
+
17
+ http_data["requests"]
18
+ .reject { |req| profiler_url?(req["url"].to_s) }
19
+ .map { |req| req.merge("profile_token" => profile.token, "profile_started_at" => profile.started_at) }
20
+ end
21
+
22
+ requests.sort_by! { |r| r["profile_started_at"] }.reverse!
23
+
24
+ render json: requests
25
+ end
26
+
27
+ private
28
+
29
+ def profiler_url?(url)
30
+ URI.parse(url).path.start_with?("/_profiler")
31
+ rescue URI::InvalidURIError
32
+ false
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class ProfilesController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def index
9
+ profiles = Profiler.storage.list(limit: params[:limit] || 50, offset: params[:offset] || 0)
10
+ render json: profiles.map(&:to_h)
11
+ end
12
+
13
+ def show
14
+ profile = Profiler.storage.load(params[:id])
15
+
16
+ unless profile
17
+ return render json: { error: "Profile not found" }, status: :not_found
18
+ end
19
+
20
+ # Recalculate AJAX collector data (since AJAX requests happen after page load)
21
+ recalculate_ajax_data(profile)
22
+
23
+ render json: profile.to_h
24
+ end
25
+
26
+ def destroy
27
+ profile = Profiler.storage.load(params[:id])
28
+ return render json: { error: "Profile not found" }, status: :not_found unless profile
29
+
30
+ Profiler.storage.delete(params[:id])
31
+ head :no_content
32
+ end
33
+
34
+ def clear
35
+ Profiler.storage.clear(type: "http")
36
+ head :no_content
37
+ end
38
+
39
+ private
40
+
41
+ def recalculate_ajax_data(profile)
42
+ # Find AJAX collector in the configured collectors
43
+ ajax_collector_class = Profiler::Collectors::AjaxCollector
44
+
45
+ if Profiler.configuration.collectors.include?(ajax_collector_class)
46
+ collector = ajax_collector_class.new(profile)
47
+ collector.collect
48
+
49
+ # Update tab metadata to reflect has_data status
50
+ if profile.instance_variable_get(:@collectors_metadata)
51
+ ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
52
+ if ajax_tab
53
+ ajax_tab[:has_data] = collector.has_data?
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class ToolbarController < Profiler::ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+ skip_before_action :check_authorization
8
+
9
+ def show
10
+ profile = Profiler.storage.load(params[:token])
11
+
12
+ unless profile
13
+ render json: { error: "Profile not found" }, status: :not_found
14
+ return
15
+ end
16
+
17
+ # Recalculate AJAX collector data (since AJAX requests happen after page load)
18
+ recalculate_ajax_data(profile)
19
+
20
+ render json: { profile: profile.to_h }
21
+ end
22
+
23
+ private
24
+
25
+ def recalculate_ajax_data(profile)
26
+ # Find AJAX collector in the configured collectors
27
+ ajax_collector_class = Profiler::Collectors::AjaxCollector
28
+
29
+ if Profiler.configuration.collectors.include?(ajax_collector_class)
30
+ collector = ajax_collector_class.new(profile)
31
+ collector.collect
32
+
33
+ # Update tab metadata to reflect has_data status
34
+ if profile.instance_variable_get(:@collectors_metadata)
35
+ ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
36
+ if ajax_tab
37
+ ajax_tab[:has_data] = collector.has_data?
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ layout "profiler/application"
8
+
9
+ before_action :check_authorization
10
+
11
+ private
12
+
13
+ def check_authorization
14
+ unless Profiler.configuration.enabled
15
+ render plain: "Profiler is disabled", status: :forbidden
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ class AssetsController < ApplicationController
5
+ skip_before_action :verify_authenticity_token
6
+ skip_before_action :check_authorization
7
+
8
+ def toolbar_js
9
+ path = Profiler::Engine.root.join("app", "assets", "builds", "profiler-toolbar.js")
10
+ js = File.read(path)
11
+ expires_in 1.hour, public: true
12
+ render plain: js, content_type: "application/javascript"
13
+ end
14
+
15
+ def main_js
16
+ path = Profiler::Engine.root.join("app", "assets", "builds", "profiler.js")
17
+ js = File.read(path)
18
+ expires_in 1.hour, public: true
19
+ render plain: js, content_type: "application/javascript"
20
+ end
21
+
22
+ def main_css
23
+ path = Profiler::Engine.root.join("app", "assets", "builds", "profiler.css")
24
+ css = File.read(path)
25
+ expires_in 1.hour, public: true
26
+ render plain: css, content_type: "text/css"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ class ProfilesController < ApplicationController
5
+ before_action :allow_iframe_embedding, only: [:show], if: -> { params[:embed] == "true" }
6
+
7
+ def index
8
+ limit = params[:limit]&.to_i || 50
9
+ offset = params[:offset]&.to_i || 0
10
+
11
+ @profiles = Profiler.storage.list(limit: limit, offset: offset)
12
+ @profiles = filter_profiles(@profiles) if params[:filter].present?
13
+ end
14
+
15
+ def show
16
+ @profile = Profiler.storage.load(params[:id])
17
+
18
+ unless @profile
19
+ render plain: "Profile not found", status: :not_found
20
+ return
21
+ end
22
+
23
+ # Recalculate AJAX collector data (since AJAX requests happen after page load)
24
+ recalculate_ajax_data(@profile)
25
+
26
+ @embedded = params[:embed] == "true"
27
+
28
+ render layout: @embedded ? "profiler/embedded" : "profiler/application"
29
+ end
30
+
31
+ def timeline
32
+ @profile = Profiler.storage.load(params[:id])
33
+ render json: @profile.collector_data("performance")
34
+ end
35
+
36
+ def database
37
+ @profile = Profiler.storage.load(params[:id])
38
+ render json: @profile.collector_data("database")
39
+ end
40
+
41
+ def views
42
+ @profile = Profiler.storage.load(params[:id])
43
+ render json: @profile.collector_data("view")
44
+ end
45
+
46
+ def cache
47
+ @profile = Profiler.storage.load(params[:id])
48
+ render json: @profile.collector_data("cache")
49
+ end
50
+
51
+ def performance
52
+ @profile = Profiler.storage.load(params[:id])
53
+ render json: @profile.collector_data("performance")
54
+ end
55
+
56
+ def flamegraph
57
+ @profile = Profiler.storage.load(params[:id])
58
+ render json: @profile.collector_data("flamegraph")
59
+ end
60
+
61
+ private
62
+
63
+ def recalculate_ajax_data(profile)
64
+ # Find AJAX collector in the configured collectors
65
+ ajax_collector_class = Profiler::Collectors::AjaxCollector
66
+
67
+ if Profiler.configuration.collectors.include?(ajax_collector_class)
68
+ collector = ajax_collector_class.new(profile)
69
+ collector.collect
70
+
71
+ # Update tab metadata to reflect has_data status
72
+ if profile.instance_variable_get(:@collectors_metadata)
73
+ ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
74
+ if ajax_tab
75
+ ajax_tab[:has_data] = collector.has_data?
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ def allow_iframe_embedding
82
+ response.headers.delete('X-Frame-Options')
83
+ # Don't set frame-ancestors CSP to allow Chrome extensions to embed
84
+ # Mark that CSP should not be set by middleware
85
+ request.env['profiler.skip_csp'] = true
86
+ end
87
+
88
+ def filter_profiles(profiles)
89
+ filtered = profiles
90
+
91
+ if params[:filter][:path].present?
92
+ filtered = filtered.select { |p| p.path.include?(params[:filter][:path]) }
93
+ end
94
+
95
+ if params[:filter][:method].present?
96
+ filtered = filtered.select { |p| p.method == params[:filter][:method] }
97
+ end
98
+
99
+ if params[:filter][:min_duration].present?
100
+ min = params[:filter][:min_duration].to_f
101
+ filtered = filtered.select { |p| p.duration >= min }
102
+ end
103
+
104
+ filtered
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Rails Profiler</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <link rel="stylesheet" href="/_profiler/assets/profiler.css">
10
+ <script src="/_profiler/assets/profiler.js" defer></script>
11
+ </head>
12
+
13
+ <body>
14
+ <%= yield %>
15
+ </body>
16
+ </html>
@@ -0,0 +1,34 @@
1
+ <!DOCTYPE html>
2
+ <html data-theme="<%= params[:theme].presence&.then { |t| %w[light dark].include?(t) ? t : 'dark' } || 'dark' %>">
3
+ <head>
4
+ <title>Rails Profiler</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <link rel="stylesheet" href="/_profiler/assets/profiler.css">
10
+ <script src="/_profiler/assets/profiler.js" defer></script>
11
+
12
+ <style>
13
+ /* Embedded overrides — tighter layout, no page chrome */
14
+ body { overflow-y: auto; }
15
+ .container { padding: 16px 20px; }
16
+ </style>
17
+
18
+ <script>
19
+ // Receive theme changes from the Chrome extension panel via postMessage.
20
+ // The extension sends: { type: 'profiler:set-theme', theme: 'light' | 'dark' }
21
+ window.addEventListener('message', function(e) {
22
+ var data = e.data;
23
+ if (data && data.type === 'profiler:set-theme' &&
24
+ (data.theme === 'light' || data.theme === 'dark')) {
25
+ document.documentElement.setAttribute('data-theme', data.theme);
26
+ }
27
+ });
28
+ </script>
29
+ </head>
30
+
31
+ <body>
32
+ <%= yield %>
33
+ </body>
34
+ </html>
@@ -0,0 +1 @@
1
+ <div id="profiler-index"></div>
@@ -0,0 +1,4 @@
1
+ <div id="profiler-show" data-embedded="<%= @embedded %>"></div>
2
+ <script type="application/json" id="profiler-show-data">
3
+ <%= @profile.to_json.html_safe %>
4
+ </script>
data/config/routes.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "profiler/mcp/server"
4
+
5
+ Profiler::Engine.routes.draw do
6
+ mount Profiler::MCP::Server.rack_app, at: "mcp"
7
+
8
+ root to: "profiles#index"
9
+
10
+ resources :profiles, only: [:index, :show] do
11
+ member do
12
+ get :timeline
13
+ get :database
14
+ get :views
15
+ get :cache
16
+ get :performance
17
+ get :flamegraph
18
+ end
19
+ end
20
+
21
+ get "assets/profiler-toolbar.js", to: "assets#toolbar_js"
22
+ get "assets/profiler.js", to: "assets#main_js"
23
+ get "assets/profiler.css", to: "assets#main_css"
24
+
25
+ namespace :api do
26
+ resources :profiles, only: [:index, :show, :destroy] do
27
+ collection { delete :clear }
28
+ end
29
+ resources :jobs, only: [:index, :show, :destroy] do
30
+ collection { delete :clear }
31
+ end
32
+ resources :outbound_http, only: [:index]
33
+ get "toolbar/:token", to: "toolbar#show"
34
+ post "ajax/link", to: "ajax#link"
35
+ end
36
+ end
data/exe/profiler-mcp ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "profiler"
6
+ require "profiler/mcp/server"
7
+
8
+ Profiler::MCP::Server.new.start(transport: :stdio)
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class AjaxCollector < BaseCollector
8
+ def icon
9
+ "🌐"
10
+ end
11
+
12
+ def priority
13
+ 25
14
+ end
15
+
16
+ def tab_config
17
+ {
18
+ key: "ajax",
19
+ label: "AJAX",
20
+ icon: icon,
21
+ priority: priority,
22
+ enabled: true,
23
+ default_active: false
24
+ }
25
+ end
26
+
27
+ def subscribe
28
+ # Passive collector - no subscriptions needed
29
+ end
30
+
31
+ def collect
32
+ # Query storage for child AJAX profiles
33
+ ajax_profiles = Profiler.storage.find_by_parent(@profile.token)
34
+
35
+ return store_data({}) if ajax_profiles.empty?
36
+
37
+ # Generate summary statistics
38
+ data = {
39
+ "total_requests" => ajax_profiles.size,
40
+ "total_duration" => ajax_profiles.sum(&:duration).round(2),
41
+ "by_method" => group_by_method(ajax_profiles),
42
+ "by_status" => group_by_status(ajax_profiles),
43
+ "requests" => ajax_profiles.map { |p| request_summary(p) }
44
+ }
45
+
46
+ store_data(data)
47
+ end
48
+
49
+ def toolbar_summary
50
+ return "" unless @data && @data["total_requests"]&.positive?
51
+
52
+ total = @data["total_requests"]
53
+ duration = @data["total_duration"]
54
+
55
+ # Color coding based on number of requests
56
+ color = if total > 20
57
+ "orange"
58
+ elsif total > 50
59
+ "red"
60
+ else
61
+ "green"
62
+ end
63
+
64
+ {
65
+ text: "#{total} AJAX (#{duration}ms)",
66
+ color: color
67
+ }
68
+ end
69
+
70
+ private
71
+
72
+ def group_by_method(profiles)
73
+ profiles.group_by(&:method).transform_values(&:count)
74
+ end
75
+
76
+ def group_by_status(profiles)
77
+ profiles.group_by { |p| status_category(p.status) }.transform_values(&:count)
78
+ end
79
+
80
+ def status_category(status)
81
+ return "unknown" unless status
82
+
83
+ case status
84
+ when 200..299
85
+ "2xx"
86
+ when 300..399
87
+ "3xx"
88
+ when 400..499
89
+ "4xx"
90
+ when 500..599
91
+ "5xx"
92
+ else
93
+ "other"
94
+ end
95
+ end
96
+
97
+ def request_summary(profile)
98
+ {
99
+ "token" => profile.token,
100
+ "path" => profile.path,
101
+ "method" => profile.method,
102
+ "status" => profile.status,
103
+ "duration" => profile.duration,
104
+ "started_at" => profile.started_at&.iso8601
105
+ }
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Collectors
5
+ class BaseCollector
6
+ attr_reader :profile
7
+
8
+ def initialize(profile)
9
+ @profile = profile
10
+ @data = {}
11
+ end
12
+
13
+ def name
14
+ self.class.name.split("::").last.gsub("Collector", "").downcase
15
+ end
16
+
17
+ def icon
18
+ "📊"
19
+ end
20
+
21
+ def priority
22
+ 100
23
+ end
24
+
25
+ # Tab configuration for dynamic tab system
26
+ def tab_config
27
+ {
28
+ key: name,
29
+ label: name.capitalize,
30
+ icon: icon,
31
+ priority: priority,
32
+ enabled: true,
33
+ default_active: false
34
+ }
35
+ end
36
+
37
+ # How to render the tab: :auto (use JSON), :custom (call render_html), :client (frontend renderer)
38
+ def render_mode
39
+ :auto
40
+ end
41
+
42
+ # For custom HTML rendering from backend (when render_mode is :custom)
43
+ def render_html(profile)
44
+ nil
45
+ end
46
+
47
+ # Whether this collector has data for the current profile
48
+ def has_data?
49
+ data = panel_content
50
+ return false if data.nil?
51
+ return false if data.is_a?(Hash) && data.empty?
52
+ return false if data.is_a?(Array) && data.empty?
53
+ true
54
+ end
55
+
56
+ def subscribe
57
+ # Override in subclasses to subscribe to ActiveSupport::Notifications
58
+ end
59
+
60
+ def collect
61
+ # Override in subclasses to collect data
62
+ end
63
+
64
+ def toolbar_summary
65
+ # Override in subclasses to provide summary for toolbar
66
+ ""
67
+ end
68
+
69
+ def panel_content
70
+ # Override in subclasses to provide full panel content
71
+ @data
72
+ end
73
+
74
+ def self.inherited(subclass)
75
+ super
76
+ # Auto-register collectors
77
+ (@descendants ||= []) << subclass
78
+ end
79
+
80
+ def self.descendants
81
+ @descendants || []
82
+ end
83
+
84
+ protected
85
+
86
+ def store_data(data)
87
+ @data = data
88
+ @profile.add_collector_data(name, data)
89
+ end
90
+ end
91
+ end
92
+ end