rails_event_viewer 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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +451 -0
  5. data/Rakefile +16 -0
  6. data/app/assets/stylesheets/rails_event_viewer/application.css +32 -0
  7. data/app/assets/stylesheets/rails_event_viewer/tailwind.css +54 -0
  8. data/app/controllers/rails_event_viewer/analytics_controller.rb +78 -0
  9. data/app/controllers/rails_event_viewer/application_controller.rb +84 -0
  10. data/app/controllers/rails_event_viewer/dashboard_controller.rb +18 -0
  11. data/app/controllers/rails_event_viewer/event_types_controller.rb +16 -0
  12. data/app/controllers/rails_event_viewer/events_controller.rb +72 -0
  13. data/app/controllers/rails_event_viewer/groups_controller.rb +52 -0
  14. data/app/helpers/rails_event_viewer/application_helper.rb +163 -0
  15. data/app/models/rails_event_viewer/application_record.rb +5 -0
  16. data/app/models/rails_event_viewer/entry.rb +34 -0
  17. data/app/views/layouts/rails_event_viewer/application.html.erb +45 -0
  18. data/app/views/rails_event_viewer/analytics/overview.html.erb +134 -0
  19. data/app/views/rails_event_viewer/dashboard/index.html.erb +113 -0
  20. data/app/views/rails_event_viewer/event_types/index.html.erb +59 -0
  21. data/app/views/rails_event_viewer/event_types/show.html.erb +102 -0
  22. data/app/views/rails_event_viewer/events/_event_row.html.erb +42 -0
  23. data/app/views/rails_event_viewer/events/_filters_sidebar.html.erb +66 -0
  24. data/app/views/rails_event_viewer/events/index.html.erb +65 -0
  25. data/app/views/rails_event_viewer/events/show.html.erb +155 -0
  26. data/app/views/rails_event_viewer/groups/index.html.erb +115 -0
  27. data/app/views/rails_event_viewer/groups/show.html.erb +134 -0
  28. data/app/views/rails_event_viewer/shared/_navigation.html.erb +47 -0
  29. data/app/views/rails_event_viewer/shared/_pagination.html.erb +60 -0
  30. data/app/views/rails_event_viewer/shared/_stat_card.html.erb +36 -0
  31. data/config/routes.rb +18 -0
  32. data/db/migrate/20241208000001_create_rails_event_viewer_entries.rb +36 -0
  33. data/lib/generators/rails_event_viewer/install/USAGE +33 -0
  34. data/lib/generators/rails_event_viewer/install/install_generator.rb +86 -0
  35. data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb +35 -0
  36. data/lib/generators/rails_event_viewer/install/templates/initializer.rb.erb +90 -0
  37. data/lib/rails_event_viewer/adapter.rb +93 -0
  38. data/lib/rails_event_viewer/adapters/active_record.rb +227 -0
  39. data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +136 -0
  40. data/lib/rails_event_viewer/adapters/memory.rb +128 -0
  41. data/lib/rails_event_viewer/adapters/null.rb +63 -0
  42. data/lib/rails_event_viewer/adapters/redis.rb +213 -0
  43. data/lib/rails_event_viewer/engine.rb +24 -0
  44. data/lib/rails_event_viewer/events_relation.rb +130 -0
  45. data/lib/rails_event_viewer/json_query.rb +111 -0
  46. data/lib/rails_event_viewer/pagy_countable.rb +31 -0
  47. data/lib/rails_event_viewer/subscriber.rb +255 -0
  48. data/lib/rails_event_viewer/time_utils.rb +24 -0
  49. data/lib/rails_event_viewer/version.rb +3 -0
  50. data/lib/rails_event_viewer.rb +122 -0
  51. data/lib/tasks/rails_event_viewer_tasks.rake +81 -0
  52. metadata +151 -0
@@ -0,0 +1,84 @@
1
+ module RailsEventViewer
2
+ class ApplicationController < ActionController::Base
3
+ include Pagy::Method
4
+
5
+ layout "rails_event_viewer/application"
6
+
7
+ before_action :authenticate!
8
+ around_action :use_viewer_time_zone
9
+
10
+ helper_method :current_timezone, :current_adapter, :analytics_supported?, :persistence_supported?
11
+
12
+ private
13
+
14
+ def authenticate!
15
+ if RailsEventViewer.http_basic_auth_enabled
16
+ expected_user = RailsEventViewer.http_basic_auth_user.to_s
17
+ expected_password = RailsEventViewer.http_basic_auth_password.to_s
18
+
19
+ if expected_user.empty? || expected_password.empty?
20
+ render plain: "RailsEventViewer: http_basic_auth_user and http_basic_auth_password must be set", status: :forbidden
21
+ return
22
+ end
23
+
24
+ authenticate_or_request_with_http_basic do |user, password|
25
+ ActiveSupport::SecurityUtils.secure_compare(user.to_s, expected_user) &
26
+ ActiveSupport::SecurityUtils.secure_compare(password.to_s, expected_password)
27
+ end
28
+ return
29
+ end
30
+
31
+ auth = RailsEventViewer.authentication
32
+ if auth.nil?
33
+ return true if Rails.env.local?
34
+
35
+ render plain: "RailsEventViewer: configure authentication or http_basic_auth", status: :forbidden
36
+ return
37
+ end
38
+
39
+ render plain: "Unauthorized", status: :unauthorized unless auth.call(self) || performed?
40
+ end
41
+
42
+ def use_viewer_time_zone(&block)
43
+ zone = ActiveSupport::TimeZone[cookies[:timezone].to_s] || ActiveSupport::TimeZone["UTC"]
44
+ Time.use_zone(zone, &block)
45
+ end
46
+
47
+ def safe_parse_date(date_string, default: nil)
48
+ return default if date_string.blank?
49
+
50
+ Date.parse(date_string)
51
+ rescue Date::Error, TypeError
52
+ default
53
+ end
54
+
55
+ def current_timezone
56
+ Time.zone.name
57
+ end
58
+
59
+ def current_adapter
60
+ RailsEventViewer.adapter
61
+ end
62
+
63
+ def analytics_supported?
64
+ current_adapter.supports_analytics?
65
+ end
66
+
67
+ def persistence_supported?
68
+ current_adapter.supports_persistence?
69
+ end
70
+
71
+ def per_page
72
+ (params[:per_page] || RailsEventViewer.per_page).to_i.clamp(1, 100)
73
+ end
74
+
75
+ # Helper to create paginated results from EventsRelation
76
+ # Uses a countable wrapper to make EventsRelation work with Pagy
77
+ def pagy_events(relation)
78
+ countable = RailsEventViewer::PagyCountable.new(relation)
79
+ pagy, records = pagy(:offset, countable, limit: per_page)
80
+
81
+ [pagy, records.to_a]
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,18 @@
1
+ module RailsEventViewer
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ @total_events = RailsEventViewer.events.count
5
+ @events_today = current_adapter.count_since(Time.current.beginning_of_day)
6
+ @event_names = current_adapter.distinct_event_names
7
+ @recent_events = RailsEventViewer.events.limit(10).to_a
8
+
9
+ if analytics_supported?
10
+ @events_by_type = current_adapter.counts_by_name(limit: 10)
11
+ @events_over_time = current_adapter.events_over_time(since: 24.hours.ago, interval: :hour)
12
+ else
13
+ @events_by_type = {}
14
+ @events_over_time = {}
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ module RailsEventViewer
2
+ class EventTypesController < ApplicationController
3
+ def index
4
+ @event_types = current_adapter.event_type_statistics
5
+ end
6
+
7
+ def show
8
+ @event_type_name = params[:name]
9
+ total_events = RailsEventViewer.events.with_name(@event_type_name)
10
+
11
+ @pagy, @events = pagy_events(total_events)
12
+ @total_count = @pagy.count
13
+ @events_today = total_events.since(Time.current.beginning_of_day).count
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,72 @@
1
+ module RailsEventViewer
2
+ class EventsController < ApplicationController
3
+ def index
4
+ relation = apply_filters(RailsEventViewer.events)
5
+ @pagy, @events = pagy_events(relation)
6
+ @event_names = current_adapter.distinct_event_names
7
+ end
8
+
9
+ def show
10
+ @event = current_adapter.find_event(params[:id])
11
+
12
+ if @event.nil?
13
+ redirect_to events_path, alert: "Event not found"
14
+ return
15
+ end
16
+
17
+ @related_events = find_related_events(@event)
18
+ end
19
+
20
+ def search
21
+ @query = params[:q]
22
+ relation = RailsEventViewer.events.search(@query)
23
+ @pagy, @events = pagy_events(relation)
24
+ @event_names = current_adapter.distinct_event_names
25
+
26
+ render :index
27
+ end
28
+
29
+ private
30
+
31
+ def apply_filters(relation)
32
+ relation = relation.with_name(params[:name]) if params[:name].present?
33
+
34
+ @start_date = safe_parse_date(params[:start_date])
35
+ @end_date = safe_parse_date(params[:end_date])
36
+ relation = relation.since(@start_date.beginning_of_day) if @start_date
37
+ relation = relation.until(@end_date.end_of_day) if @end_date
38
+
39
+ if params[:tag_key].present?
40
+ relation = relation.with_tag(params[:tag_key], params[:tag_value].presence)
41
+ end
42
+
43
+ if params[:context_key].present?
44
+ relation = relation.with_context(params[:context_key], params[:context_value].presence)
45
+ end
46
+
47
+ if params[:q].present?
48
+ relation = relation.search(params[:q])
49
+ end
50
+
51
+ relation
52
+ end
53
+
54
+ def find_related_events(event)
55
+ context = event.respond_to?(:context) ? event.context : event[:context]
56
+ return [] unless context.present?
57
+
58
+ # Find events with matching request_id in context
59
+ request_id = context["request_id"] || context[:request_id]
60
+ return [] unless request_id
61
+
62
+ event_id = event.respond_to?(:id) ? event.id : event[:id]
63
+
64
+ RailsEventViewer.events
65
+ .with_context("request_id", request_id)
66
+ .limit(11)
67
+ .to_a
68
+ .reject { |e| (e.respond_to?(:id) ? e.id : e[:id]) == event_id }
69
+ .first(10)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,52 @@
1
+ module RailsEventViewer
2
+ class GroupsController < ApplicationController
3
+ def index
4
+ @group_keys = RailsEventViewer.group_keys
5
+ @selected_key = params[:key].presence_in(@group_keys.map(&:to_s)) || @group_keys.first&.to_s
6
+ @source = source_param
7
+
8
+ if @selected_key.present?
9
+ @groups = current_adapter.group_instances(
10
+ @selected_key,
11
+ source: @source,
12
+ limit: 100
13
+ )
14
+ else
15
+ @groups = []
16
+ end
17
+ end
18
+
19
+ def show
20
+ @key = params[:key]
21
+ @value = params[:id]
22
+ @source = source_param
23
+
24
+ events = if @source == :tags
25
+ RailsEventViewer.events.with_tag(@key, @value)
26
+ else
27
+ RailsEventViewer.events.with_context(@key, @value)
28
+ end
29
+
30
+ @pagy, @events = pagy_events(events)
31
+ @total_count = @pagy.count
32
+ @event_names = @events.map { |e| event_name(e) }.uniq
33
+
34
+ @first_event_at, @last_event_at = current_adapter.event_time_span(events)
35
+ @duration = @last_event_at - @first_event_at if @first_event_at && @last_event_at
36
+ end
37
+
38
+ private
39
+
40
+ def source_param
41
+ params[:source].presence_in(%w[context tags])&.to_sym || :context
42
+ end
43
+
44
+ def event_name(event)
45
+ event.respond_to?(:name) ? event.name : event[:name]
46
+ end
47
+
48
+ def event_occurred_at(event)
49
+ event.respond_to?(:occurred_at) ? event.occurred_at : event[:occurred_at]
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,163 @@
1
+ module RailsEventViewer
2
+ module ApplicationHelper
3
+ def nav_link_class(controller)
4
+ base_classes = "rounded-md px-3 py-2 text-sm font-medium"
5
+
6
+ if controller_name == controller
7
+ "#{base_classes} bg-indigo-700 text-white"
8
+ else
9
+ "#{base_classes} text-indigo-200 hover:bg-indigo-500 hover:text-white"
10
+ end
11
+ end
12
+
13
+ # Format event time - works with both Entry model and Hash
14
+ def format_event_time(event)
15
+ time = event_occurred_at(event)
16
+ return "N/A" unless time
17
+
18
+ time.strftime("%Y-%m-%d %H:%M:%S.%3N")
19
+ end
20
+
21
+ def time_ago_with_title(time)
22
+ return "N/A" unless time
23
+
24
+ content_tag(:span, time_ago_in_words(time) + " ago", title: time.strftime("%Y-%m-%d %H:%M:%S %Z"))
25
+ end
26
+
27
+ def truncate_json(json, length: 100)
28
+ text = json.is_a?(Hash) ? json.to_json : json.to_s
29
+ truncate(text, length: length)
30
+ end
31
+
32
+ def json_tree(data, depth: 0, max_depth: nil)
33
+ return content_tag(:span, "null", class: "text-gray-400") if data.nil?
34
+ return content_tag(:span, data.to_json, class: json_value_class(data)) unless data.is_a?(Hash) || data.is_a?(Array)
35
+ return content_tag(:span, collapsed_summary(data), class: "text-gray-400") if max_depth && depth >= max_depth
36
+
37
+ if data.is_a?(Hash)
38
+ render_hash_tree(data, depth, max_depth)
39
+ else
40
+ render_array_tree(data, depth, max_depth)
41
+ end
42
+ end
43
+
44
+ def timezone_options
45
+ ActiveSupport::TimeZone.all.map { |tz| [tz.to_s, tz.name] }
46
+ end
47
+
48
+ # Helper to get attribute from either Entry model or Hash
49
+ def event_attribute(event, attr)
50
+ if event.respond_to?(attr)
51
+ event.public_send(attr)
52
+ elsif event.is_a?(Hash)
53
+ event[attr] || event[attr.to_s]
54
+ end
55
+ end
56
+
57
+ def event_id(event)
58
+ event_attribute(event, :id)
59
+ end
60
+
61
+ def event_name(event)
62
+ event_attribute(event, :name)
63
+ end
64
+
65
+ def event_payload(event)
66
+ event_attribute(event, :payload) || {}
67
+ end
68
+
69
+ def event_tags(event)
70
+ event_attribute(event, :tags) || {}
71
+ end
72
+
73
+ def event_context(event)
74
+ event_attribute(event, :context) || {}
75
+ end
76
+
77
+ def event_occurred_at(event)
78
+ event_attribute(event, :occurred_at)
79
+ end
80
+
81
+ def event_source_file(event)
82
+ event_attribute(event, :source_file)
83
+ end
84
+
85
+ def event_source_line(event)
86
+ event_attribute(event, :source_line)
87
+ end
88
+
89
+ def event_source_label(event)
90
+ event_attribute(event, :source_label)
91
+ end
92
+
93
+ def event_short_filepath(event)
94
+ filepath = event_source_file(event)
95
+ return nil unless filepath
96
+
97
+ filepath.gsub(%r{.*/app/}, "app/")
98
+ end
99
+
100
+ def format_duration(seconds)
101
+ return "-" unless seconds
102
+
103
+ duration = ActiveSupport::Duration.build(seconds)
104
+ parts = duration.parts
105
+
106
+ return "0 seconds" if parts.empty? || parts == { seconds: 0 }
107
+
108
+ parts.map { |unit, value|
109
+ unit_name = value == 1 ? unit.to_s.singularize : unit.to_s
110
+ "#{value} #{unit_name}"
111
+ }.to_sentence
112
+ end
113
+
114
+ private
115
+
116
+ def json_value_class(value)
117
+ case value
118
+ when String
119
+ "text-green-600"
120
+ when Numeric
121
+ "text-blue-600"
122
+ when TrueClass, FalseClass
123
+ "text-purple-600"
124
+ else
125
+ "text-gray-900"
126
+ end
127
+ end
128
+
129
+ def collapsed_summary(data)
130
+ if data.is_a?(Hash)
131
+ data.empty? ? "{}" : "{#{data.size} #{"key".pluralize(data.size)}}"
132
+ else
133
+ data.empty? ? "[]" : "[#{data.size} #{"item".pluralize(data.size)}]"
134
+ end
135
+ end
136
+
137
+ def render_hash_tree(hash, depth, max_depth)
138
+ return content_tag(:span, "{}", class: "text-gray-400") if hash.empty?
139
+
140
+ content_tag(:div, class: "pl-4") do
141
+ hash.map do |key, value|
142
+ content_tag(:div, class: "py-0.5") do
143
+ content_tag(:span, "#{key}:", class: "text-gray-600 font-medium mr-2") +
144
+ json_tree(value, depth: depth + 1, max_depth: max_depth)
145
+ end
146
+ end.join.html_safe
147
+ end
148
+ end
149
+
150
+ def render_array_tree(array, depth, max_depth)
151
+ return content_tag(:span, "[]", class: "text-gray-400") if array.empty?
152
+
153
+ content_tag(:div, class: "pl-4") do
154
+ array.each_with_index.map do |value, index|
155
+ content_tag(:div, class: "py-0.5") do
156
+ content_tag(:span, "[#{index}]:", class: "text-gray-400 mr-2") +
157
+ json_tree(value, depth: depth + 1, max_depth: max_depth)
158
+ end
159
+ end.join.html_safe
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,5 @@
1
+ module RailsEventViewer
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,34 @@
1
+ module RailsEventViewer
2
+ class Entry < ApplicationRecord
3
+ self.table_name = "rails_event_viewer_entries"
4
+
5
+ validates :name, presence: true
6
+ validates :occurred_at, presence: true
7
+
8
+ def source_location
9
+ {
10
+ filepath: source_file,
11
+ lineno: source_line,
12
+ label: source_label
13
+ }.compact
14
+ end
15
+
16
+ def short_filepath
17
+ return nil unless source_file
18
+
19
+ source_file.gsub(%r{.*/app/}, "app/")
20
+ end
21
+
22
+ def tagged?
23
+ tags.present? && tags.any?
24
+ end
25
+
26
+ def has_context?
27
+ context.present? && context.any?
28
+ end
29
+
30
+ def tag(key)
31
+ tags&.dig(key.to_s)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full bg-gray-100">
3
+ <head>
4
+ <title><%= content_for(:title) || "Rails Event Viewer" %></title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <!-- Tailwind CSS CDN (replace with bundled CSS in production) -->
10
+ <script src="https://cdn.tailwindcss.com"></script>
11
+
12
+ <!-- Chart.js for Chartkick (with date adapter for time-series charts) -->
13
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
14
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/chartkick@5.0.1/dist/chartkick.min.js"></script>
16
+
17
+ <%= stylesheet_link_tag "rails_event_viewer/application", media: "all", "data-turbo-track": "reload" %>
18
+ <%= javascript_importmap_tags if respond_to?(:javascript_importmap_tags) %>
19
+
20
+ <%= yield :head %>
21
+ </head>
22
+ <body class="h-full">
23
+ <div class="min-h-full">
24
+ <%= render "rails_event_viewer/shared/navigation" %>
25
+
26
+ <main class="py-6">
27
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
28
+ <% if notice.present? %>
29
+ <div class="mb-4 rounded-md bg-green-50 p-4">
30
+ <p class="text-sm font-medium text-green-800"><%= notice %></p>
31
+ </div>
32
+ <% end %>
33
+
34
+ <% if alert.present? %>
35
+ <div class="mb-4 rounded-md bg-red-50 p-4">
36
+ <p class="text-sm font-medium text-red-800"><%= alert %></p>
37
+ </div>
38
+ <% end %>
39
+
40
+ <%= yield %>
41
+ </div>
42
+ </main>
43
+ </div>
44
+ </body>
45
+ </html>
@@ -0,0 +1,134 @@
1
+ <% content_for(:title, "Analytics - Rails Event Viewer") %>
2
+
3
+ <div class="space-y-6">
4
+ <div class="flex justify-between items-center">
5
+ <h1 class="text-2xl font-bold text-gray-900">Analytics</h1>
6
+
7
+ <!-- Date range picker -->
8
+ <%= form_with url: rails_event_viewer.analytics_overview_path, method: :get, class: "flex items-center space-x-3" do |f| %>
9
+ <%= f.date_field :start_date,
10
+ value: @date_range.begin.to_date,
11
+ class: "rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 py-2 px-3" %>
12
+ <span class="text-gray-500">to</span>
13
+ <%= f.date_field :end_date,
14
+ value: @date_range.end.to_date,
15
+ class: "rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 py-2 px-3" %>
16
+ <%= f.submit "Apply",
17
+ class: "bg-indigo-600 text-white px-5 py-2.5 rounded-md font-medium hover:bg-indigo-700 cursor-pointer" %>
18
+ <% end %>
19
+ </div>
20
+
21
+ <!-- Overview stats -->
22
+ <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
23
+ <%= render "rails_event_viewer/shared/stat_card",
24
+ title: "Total Events",
25
+ value: number_with_delimiter(@total_events),
26
+ icon: "database" %>
27
+ <%= render "rails_event_viewer/shared/stat_card",
28
+ title: "Unique Types",
29
+ value: @unique_event_types,
30
+ icon: "tag" %>
31
+ <%= render "rails_event_viewer/shared/stat_card",
32
+ title: "Avg/Hour",
33
+ value: @avg_events_per_hour,
34
+ icon: "clock" %>
35
+ <%= render "rails_event_viewer/shared/stat_card",
36
+ title: "Peak Period",
37
+ value: @peak_period ? @peak_period.first.to_s : "N/A",
38
+ icon: "calendar" %>
39
+ </div>
40
+
41
+ <!-- Charts -->
42
+ <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
43
+ <div class="bg-white rounded-lg shadow p-6 overflow-hidden">
44
+ <h3 class="text-lg font-medium text-gray-900 mb-4">Events Over Time</h3>
45
+ <div class="h-72 overflow-hidden">
46
+ <% if @events_over_time.any? %>
47
+ <%= line_chart @events_over_time,
48
+ id: "analytics-timeline",
49
+ height: "100%",
50
+ colors: ["#4F46E5"],
51
+ library: {
52
+ plugins: { legend: { display: false } },
53
+ scales: { y: { beginAtZero: true } }
54
+ } %>
55
+ <% else %>
56
+ <div class="flex items-center justify-center h-full text-gray-400">
57
+ No data for the selected period
58
+ </div>
59
+ <% end %>
60
+ </div>
61
+ </div>
62
+
63
+ <div class="bg-white rounded-lg shadow p-6 overflow-hidden">
64
+ <h3 class="text-lg font-medium text-gray-900 mb-4">Events by Type</h3>
65
+ <div class="h-72 overflow-hidden">
66
+ <% if @events_by_type.any? %>
67
+ <%= bar_chart @events_by_type,
68
+ id: "analytics-by-type",
69
+ height: "100%",
70
+ colors: ["#4F46E5"],
71
+ library: {
72
+ indexAxis: 'y',
73
+ plugins: { legend: { display: false } }
74
+ } %>
75
+ <% else %>
76
+ <div class="flex items-center justify-center h-full text-gray-400">
77
+ No data for the selected period
78
+ </div>
79
+ <% end %>
80
+ </div>
81
+ </div>
82
+ </div>
83
+
84
+ <!-- Event type breakdown table -->
85
+ <div class="bg-white rounded-lg shadow">
86
+ <div class="px-6 py-4 border-b border-gray-200">
87
+ <h3 class="text-lg font-medium text-gray-900">Event Type Breakdown</h3>
88
+ </div>
89
+
90
+ <% if @events_by_type.any? %>
91
+ <div class="overflow-x-auto">
92
+ <table class="min-w-full divide-y divide-gray-200">
93
+ <thead class="bg-gray-50">
94
+ <tr>
95
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event Type</th>
96
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Count</th>
97
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Percentage</th>
98
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Distribution</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody class="divide-y divide-gray-200">
102
+ <% @events_by_type.each do |name, count| %>
103
+ <% percentage = @total_events > 0 ? (count.to_f / @total_events * 100) : 0 %>
104
+ <tr class="hover:bg-gray-50">
105
+ <td class="px-6 py-4 whitespace-nowrap">
106
+ <%= link_to rails_event_viewer.events_path(name: name), class: "text-indigo-600 hover:text-indigo-900" do %>
107
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
108
+ <%= name %>
109
+ </span>
110
+ <% end %>
111
+ </td>
112
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
113
+ <%= number_with_delimiter(count) %>
114
+ </td>
115
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
116
+ <%= number_to_percentage(percentage, precision: 1) %>
117
+ </td>
118
+ <td class="px-6 py-4 whitespace-nowrap">
119
+ <div class="w-full bg-gray-200 rounded-full h-2">
120
+ <div class="bg-indigo-600 h-2 rounded-full" style="width: <%= [percentage, 100].min %>%"></div>
121
+ </div>
122
+ </td>
123
+ </tr>
124
+ <% end %>
125
+ </tbody>
126
+ </table>
127
+ </div>
128
+ <% else %>
129
+ <div class="px-6 py-12 text-center text-gray-500">
130
+ No events for the selected period.
131
+ </div>
132
+ <% end %>
133
+ </div>
134
+ </div>