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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +451 -0
- data/Rakefile +16 -0
- data/app/assets/stylesheets/rails_event_viewer/application.css +32 -0
- data/app/assets/stylesheets/rails_event_viewer/tailwind.css +54 -0
- data/app/controllers/rails_event_viewer/analytics_controller.rb +78 -0
- data/app/controllers/rails_event_viewer/application_controller.rb +84 -0
- data/app/controllers/rails_event_viewer/dashboard_controller.rb +18 -0
- data/app/controllers/rails_event_viewer/event_types_controller.rb +16 -0
- data/app/controllers/rails_event_viewer/events_controller.rb +72 -0
- data/app/controllers/rails_event_viewer/groups_controller.rb +52 -0
- data/app/helpers/rails_event_viewer/application_helper.rb +163 -0
- data/app/models/rails_event_viewer/application_record.rb +5 -0
- data/app/models/rails_event_viewer/entry.rb +34 -0
- data/app/views/layouts/rails_event_viewer/application.html.erb +45 -0
- data/app/views/rails_event_viewer/analytics/overview.html.erb +134 -0
- data/app/views/rails_event_viewer/dashboard/index.html.erb +113 -0
- data/app/views/rails_event_viewer/event_types/index.html.erb +59 -0
- data/app/views/rails_event_viewer/event_types/show.html.erb +102 -0
- data/app/views/rails_event_viewer/events/_event_row.html.erb +42 -0
- data/app/views/rails_event_viewer/events/_filters_sidebar.html.erb +66 -0
- data/app/views/rails_event_viewer/events/index.html.erb +65 -0
- data/app/views/rails_event_viewer/events/show.html.erb +155 -0
- data/app/views/rails_event_viewer/groups/index.html.erb +115 -0
- data/app/views/rails_event_viewer/groups/show.html.erb +134 -0
- data/app/views/rails_event_viewer/shared/_navigation.html.erb +47 -0
- data/app/views/rails_event_viewer/shared/_pagination.html.erb +60 -0
- data/app/views/rails_event_viewer/shared/_stat_card.html.erb +36 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20241208000001_create_rails_event_viewer_entries.rb +36 -0
- data/lib/generators/rails_event_viewer/install/USAGE +33 -0
- data/lib/generators/rails_event_viewer/install/install_generator.rb +86 -0
- data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb +35 -0
- data/lib/generators/rails_event_viewer/install/templates/initializer.rb.erb +90 -0
- data/lib/rails_event_viewer/adapter.rb +93 -0
- data/lib/rails_event_viewer/adapters/active_record.rb +227 -0
- data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +136 -0
- data/lib/rails_event_viewer/adapters/memory.rb +128 -0
- data/lib/rails_event_viewer/adapters/null.rb +63 -0
- data/lib/rails_event_viewer/adapters/redis.rb +213 -0
- data/lib/rails_event_viewer/engine.rb +24 -0
- data/lib/rails_event_viewer/events_relation.rb +130 -0
- data/lib/rails_event_viewer/json_query.rb +111 -0
- data/lib/rails_event_viewer/pagy_countable.rb +31 -0
- data/lib/rails_event_viewer/subscriber.rb +255 -0
- data/lib/rails_event_viewer/time_utils.rb +24 -0
- data/lib/rails_event_viewer/version.rb +3 -0
- data/lib/rails_event_viewer.rb +122 -0
- data/lib/tasks/rails_event_viewer_tasks.rake +81 -0
- 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,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>
|