# frozen_string_literal: true # AnalyticsController - Overview dashboard with statistics and charts class AnalyticsController < ApplicationController # All actions require authentication def index authorize :analytics, :index? # Time period selector (default: last 24 hours) @time_period = params[:period]&.to_sym || :day @start_time = calculate_start_time(@time_period) # Cache TTL based on time period cache_ttl = case @time_period when :hour then 5.minutes when :day then 1.hour when :week then 6.hours when :month then 12.hours else 1.hour end # Cache key includes period and start_time (hour-aligned for consistency) cache_key_base = "analytics/#{@time_period}/#{@start_time.to_i}" # Core statistics - cached @total_events = Rails.cache.fetch("#{cache_key_base}/total_events", expires_in: cache_ttl) do Event.where("timestamp >= ?", @start_time).count end @total_rules = Rails.cache.fetch("analytics/total_rules", expires_in: 5.minutes) do Rule.enabled.count end @network_ranges_with_events = Rails.cache.fetch("analytics/network_ranges_with_events", expires_in: 5.minutes) do NetworkRange.with_events.count end @total_network_ranges = Rails.cache.fetch("analytics/total_network_ranges", expires_in: 5.minutes) do NetworkRange.count end # Event breakdown by action - cached @event_breakdown = Rails.cache.fetch("#{cache_key_base}/event_breakdown", expires_in: cache_ttl) do Event.where("timestamp >= ?", @start_time) .group(:waf_action) .count .transform_keys do |action_id| case action_id when 0 then 'allow' when 1 then 'deny' when 2 then 'redirect' when 3 then 'challenge' else 'unknown' end end end # Top countries by event count - cached (this is the expensive JOIN query) @top_countries = Rails.cache.fetch("#{cache_key_base}/top_countries", expires_in: cache_ttl) do Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") .where("timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time) .group("network_ranges.country") .count .sort_by { |_, count| -count } .first(10) end # Top blocked IPs - cached @top_blocked_ips = Rails.cache.fetch("#{cache_key_base}/top_blocked_ips", expires_in: cache_ttl) do Event.where("timestamp >= ?", @start_time) .where(waf_action: 1) # deny action in enum .group(:ip_address) .count .sort_by { |_, count| -count } .first(10) end # Network range intelligence breakdown - cached @network_intelligence = Rails.cache.fetch("analytics/network_intelligence", expires_in: 10.minutes) do { datacenter_ranges: NetworkRange.datacenter.count, vpn_ranges: NetworkRange.vpn.count, proxy_ranges: NetworkRange.proxy.count, total_ranges: NetworkRange.count } end # Recent activity - minimal cache for freshness @recent_events = Rails.cache.fetch("analytics/recent_events", expires_in: 1.minute) do Event.recent.limit(10).to_a end @recent_rules = Rails.cache.fetch("analytics/recent_rules", expires_in: 5.minutes) do Rule.order(created_at: :desc).limit(5).to_a end # System health indicators - cached @system_health = Rails.cache.fetch("#{cache_key_base}/system_health", expires_in: cache_ttl) do { total_users: User.count, active_rules: Rule.enabled.count, disabled_rules: Rule.where(enabled: false).count, recent_errors: Event.where("timestamp >= ? AND waf_action = ?", @start_time, 1).count # 1 = deny } end # Job queue statistics - short cache for near real-time @job_statistics = Rails.cache.fetch("analytics/job_statistics", expires_in: 30.seconds) do calculate_job_statistics end # Prepare data for charts - split caching for current vs historical data @chart_data = prepare_chart_data_with_split_cache(cache_key_base, cache_ttl) respond_to do |format| format.html format.turbo_stream end end def networks authorize :analytics, :index? # Time period selector (default: last 24 hours) @time_period = params[:period]&.to_sym || :day @start_time = calculate_start_time(@time_period) # Top networks by request volume @top_networks = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ? OR events.timestamp IS NULL", @start_time) .group("network_ranges.id", "network_ranges.network", "network_ranges.company", "network_ranges.asn", "network_ranges.country", "network_ranges.is_datacenter", "network_ranges.is_vpn", "network_ranges.is_proxy") .select("network_ranges.*, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips") .order("event_count DESC") .limit(50) # Network type breakdown with traffic stats @network_breakdown = calculate_network_type_stats(@start_time) # Company breakdown for top traffic sources @top_companies = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ? AND network_ranges.company IS NOT NULL", @start_time) .group("network_ranges.company") .select("network_ranges.company, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") .order("event_count DESC") .limit(20) # ASN breakdown @top_asns = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ? AND network_ranges.asn IS NOT NULL", @start_time) .group("network_ranges.asn", "network_ranges.asn_org") .select("network_ranges.asn, network_ranges.asn_org, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") .order("event_count DESC") .limit(15) # Geographic breakdown @top_countries = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time) .group("network_ranges.country") .select("network_ranges.country, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") .order("event_count DESC") .limit(15) # Suspicious network activity patterns @suspicious_patterns = calculate_suspicious_patterns(@start_time) respond_to do |format| format.html format.json { render json: network_analytics_json } end end private def calculate_start_time(period) # Snap to hour/day boundaries for cacheability # Instead of rolling windows that change every second, use fixed boundaries case period when :hour # Last complete hour: if it's 13:45, show 12:00-13:00 1.hour.ago.beginning_of_hour when :day # Last 24 complete hours from current hour boundary 24.hours.ago.beginning_of_hour when :week # Last 7 complete days from today's start 7.days.ago.beginning_of_day when :month # Last 30 complete days from today's start 30.days.ago.beginning_of_day else 24.hours.ago.beginning_of_hour end end def prepare_chart_data_with_split_cache(cache_key_base, cache_ttl) # Split timeline into historical (completed hours) and current (incomplete hour) # Historical hours are cached for full TTL, current hour cached briefly for freshness # Cache historical hours (1-23 hours ago) - these are complete and won't change # No expiration - will stick around until evicted by cache store historical_timeline = Rails.cache.fetch("#{cache_key_base}/chart_historical") do historical_start = 23.hours.ago.beginning_of_hour events_by_hour = Event.where("timestamp >= ? AND timestamp < ?", historical_start, Time.current.beginning_of_hour) .group("DATE_TRUNC('hour', timestamp)") .count (1..23).map do |hour_ago| hour_time = hour_ago.hours.ago.beginning_of_hour hour_key = hour_time.utc { time_iso: hour_time.iso8601, total: events_by_hour[hour_key] || 0 } end end # Current hour (0 hours ago) - cache very briefly since it's actively accumulating current_hour_data = Rails.cache.fetch("#{cache_key_base}/chart_current_hour", expires_in: 1.minute) do hour_time = Time.current.beginning_of_hour count = Event.where("timestamp >= ?", hour_time).count { time_iso: hour_time.iso8601, total: count } end # Combine current + historical for full 24-hour timeline timeline_data = [current_hour_data] + historical_timeline # Action distribution and other chart data (cached with main cache) other_chart_data = Rails.cache.fetch("#{cache_key_base}/chart_metadata", expires_in: cache_ttl) do action_distribution = @event_breakdown.map do |action, count| { action: action.humanize, count: count, percentage: ((count.to_f / [@total_events, 1].max) * 100).round(1) } end { actions: action_distribution, countries: @top_countries.map { |country, count| { country: country, count: count } }, network_types: [ { type: "Datacenter", count: @network_intelligence[:datacenter_ranges] }, { type: "VPN", count: @network_intelligence[:vpn_ranges] }, { type: "Proxy", count: @network_intelligence[:proxy_ranges] }, { type: "Standard", count: @network_intelligence[:total_ranges] - @network_intelligence[:datacenter_ranges] - @network_intelligence[:vpn_ranges] - @network_intelligence[:proxy_ranges] } ] } end # Merge timeline with other chart data other_chart_data.merge(timeline: timeline_data) end def prepare_chart_data # Legacy method - kept for reference but no longer used # Events over time (hourly buckets) - use @start_time for consistency events_by_hour = Event.where("timestamp >= ?", @start_time) .group("DATE_TRUNC('hour', timestamp)") .count # Convert to chart format - snap to hour boundaries for cacheability timeline_data = (0..23).map do |hour_ago| # Use hour boundaries instead of rolling times hour_time = hour_ago.hours.ago.beginning_of_hour hour_key = hour_time.utc { # Store as ISO string for JavaScript to handle timezone conversion time_iso: hour_time.iso8601, total: events_by_hour[hour_key] || 0 } end # Action distribution for pie chart action_distribution = @event_breakdown.map do |action, count| { action: action.humanize, count: count, percentage: ((count.to_f / [@total_events, 1].max) * 100).round(1) } end { timeline: timeline_data, actions: action_distribution, countries: @top_countries.map { |country, count| { country: country, count: count } }, network_types: [ { type: "Datacenter", count: @network_intelligence[:datacenter_ranges] }, { type: "VPN", count: @network_intelligence[:vpn_ranges] }, { type: "Proxy", count: @network_intelligence[:proxy_ranges] }, { type: "Standard", count: @network_intelligence[:total_ranges] - @network_intelligence[:datacenter_ranges] - @network_intelligence[:vpn_ranges] - @network_intelligence[:proxy_ranges] } ] } end def calculate_network_type_stats(start_time) # Get all network types with their traffic statistics network_types = [ { type: 'datacenter', label: 'Datacenter' }, { type: 'vpn', label: 'VPN' }, { type: 'proxy', label: 'Proxy' } ] results = {} total_events = Event.where("timestamp >= ?", start_time).count network_types.each do |network_type| scope = case network_type[:type] when 'datacenter' then NetworkRange.datacenter when 'vpn' then NetworkRange.vpn when 'proxy' then NetworkRange.proxy end if scope network_stats = scope.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ? OR events.timestamp IS NULL", start_time) .select("COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") .first results[network_type[:type]] = { label: network_type[:label], networks: network_stats.network_count, events: network_stats.event_count, unique_ips: network_stats.unique_ips, percentage: total_events > 0 ? ((network_stats.event_count.to_f / total_events) * 100).round(1) : 0 } end end # Calculate standard networks (everything else) standard_stats = NetworkRange.where(is_datacenter: false, is_vpn: false, is_proxy: false) .joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ? OR events.timestamp IS NULL", start_time) .select("COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") .first results['standard'] = { label: 'Standard', networks: standard_stats.network_count, events: standard_stats.event_count, unique_ips: standard_stats.unique_ips, percentage: total_events > 0 ? ((standard_stats.event_count.to_f / total_events) * 100).round(1) : 0 } results end def calculate_suspicious_patterns(start_time) patterns = {} # High volume networks (top 1% by request count) total_networks = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ?", start_time) .distinct.count high_volume_threshold = [total_networks * 0.01, 1].max high_volume_networks = NetworkRange.joins("INNER JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ?", start_time) .group("network_ranges.id") .having("COUNT(events.id) > ?", Event.where("timestamp >= ?", start_time).count / total_networks) .count patterns[:high_volume] = { count: high_volume_networks.count, networks: high_volume_networks.keys } # Networks with high deny rates (> 50% blocked requests) high_deny_networks = NetworkRange.joins("INNER JOIN events ON events.ip_address <<= network_ranges.network") .where("events.timestamp >= ?", start_time) .group("network_ranges.id") .select("network_ranges.id, COUNT(CASE WHEN events.waf_action = 1 THEN 1 END) as denied_count, COUNT(events.id) as total_count") .having("COUNT(CASE WHEN events.waf_action = 1 THEN 1 END)::float / COUNT(events.id) > 0.5") .having("COUNT(events.id) >= 10") # minimum threshold patterns[:high_deny_rate] = { count: high_deny_networks.count, network_ids: high_deny_networks.map(&:id) } # Networks appearing as multiple subnets (potential botnets) company_subnets = NetworkRange.where("company IS NOT NULL") .where("timestamp >= ? OR timestamp IS NULL", start_time) .group(:company) .select(:company, "COUNT(DISTINCT network) as subnet_count") .having("COUNT(DISTINCT network) > 5") .order("subnet_count DESC") .limit(10) patterns[:distributed_companies] = company_subnets.map do |company| { company: company.company, subnets: company.subnet_count } end patterns end def network_analytics_json { top_networks: @top_networks.map { |network| { id: network.id, cidr: network.cidr, company: network.company, asn: network.asn, country: network.country, network_type: network.network_type, event_count: network.event_count, unique_ips: network.unique_ips } }, network_breakdown: @network_breakdown, top_companies: @top_companies, top_asns: @top_asns, top_countries: @top_countries, suspicious_patterns: @suspicious_patterns } end def calculate_job_statistics # Get job queue information from SolidQueue begin total_jobs = SolidQueue::Job.count pending_jobs = SolidQueue::Job.where(finished_at: nil).count recent_jobs = SolidQueue::Job.where('created_at > ?', 1.hour.ago).count # Get jobs by queue name queue_breakdown = SolidQueue::Job.group(:queue_name).count # Get recent job activity recent_enqueued = SolidQueue::Job.where('created_at > ?', 1.hour.ago).count # Calculate health status health_status = if pending_jobs > 100 'warning' elsif pending_jobs > 500 'critical' else 'healthy' end { total_jobs: total_jobs, pending_jobs: pending_jobs, recent_enqueued: recent_enqueued, queue_breakdown: queue_breakdown, health_status: health_status } rescue => e Rails.logger.error "Failed to calculate job statistics: #{e.message}" { total_jobs: 0, pending_jobs: 0, recent_enqueued: 0, queue_breakdown: {}, health_status: 'error', error: e.message } end end end