diff --git a/app/controllers/analytics_controller.rb b/app/controllers/analytics_controller.rb index ec73907..4c3c7e6 100644 --- a/app/controllers/analytics_controller.rb +++ b/app/controllers/analytics_controller.rb @@ -11,17 +11,41 @@ class AnalyticsController < ApplicationController @time_period = params[:period]&.to_sym || :day @start_time = calculate_start_time(@time_period) - # Core statistics - @total_events = Event.where("timestamp >= ?", @start_time).count - @total_rules = Rule.enabled.count - @network_ranges_with_events = NetworkRange.with_events.count - @total_network_ranges = NetworkRange.count + # 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 - # Event breakdown by action - @event_breakdown = Event.where("timestamp >= ?", @start_time) - .group(:waf_action) - .count - .transform_keys do |action_id| + # 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' @@ -30,45 +54,64 @@ class AnalyticsController < ApplicationController else 'unknown' end end + end - # Top countries by event count - @top_countries = 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) + # 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 - @top_blocked_ips = Event.where("timestamp >= ?", @start_time) - .where(waf_action: 1) # deny action in enum - .group(:ip_address) - .count - .sort_by { |_, count| -count } - .first(10) + # 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 - @network_intelligence = { - datacenter_ranges: NetworkRange.datacenter.count, - vpn_ranges: NetworkRange.vpn.count, - proxy_ranges: NetworkRange.proxy.count, - total_ranges: NetworkRange.count - } + # 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 - @recent_events = Event.recent.limit(10) - @recent_rules = Rule.order(created_at: :desc).limit(5) + # 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 - # System health indicators - @system_health = { - 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 - } + @recent_rules = Rails.cache.fetch("analytics/recent_rules", expires_in: 5.minutes) do + Rule.order(created_at: :desc).limit(5).to_a + end - # Prepare data for charts - @chart_data = prepare_chart_data + # 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 @@ -130,30 +173,99 @@ class AnalyticsController < ApplicationController 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 - 1.hour.ago + # Last complete hour: if it's 13:45, show 12:00-13:00 + 1.hour.ago.beginning_of_hour when :day - 24.hours.ago + # Last 24 complete hours from current hour boundary + 24.hours.ago.beginning_of_hour when :week - 1.week.ago + # Last 7 complete days from today's start + 7.days.ago.beginning_of_day when :month - 1.month.ago + # Last 30 complete days from today's start + 30.days.ago.beginning_of_day else - 24.hours.ago + 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 - # Events over time (hourly buckets for last 24 hours) - events_by_hour = Event.where("timestamp >= ?", 24.hours.ago) + # 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 - keep everything in UTC for consistency + # Convert to chart format - snap to hour boundaries for cacheability timeline_data = (0..23).map do |hour_ago| - hour_time = hour_ago.hours.ago - hour_key = hour_time.utc.beginning_of_hour + # 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 @@ -311,4 +423,46 @@ class AnalyticsController < ApplicationController 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 \ No newline at end of file diff --git a/app/controllers/dsns_controller.rb b/app/controllers/dsns_controller.rb index 6704a60..4b0044c 100644 --- a/app/controllers/dsns_controller.rb +++ b/app/controllers/dsns_controller.rb @@ -2,19 +2,12 @@ class DsnsController < ApplicationController before_action :require_authentication - before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable] + before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable, :destroy] before_action :authorize_dsn_management, except: [:index, :show] # GET /dsns def index @dsns = policy_scope(Dsn).order(created_at: :desc) - - # Generate environment DSNs using default DSN key or first enabled DSN - default_dsn = Dsn.enabled.first - if default_dsn - @external_dsn = generate_external_dsn(default_dsn.key) - @internal_dsn = generate_internal_dsn(default_dsn.key) - end end # GET /dsns/new @@ -64,6 +57,20 @@ class DsnsController < ApplicationController redirect_to @dsn, notice: 'DSN was enabled.' end + # DELETE /dsns/:id + def destroy + # Only allow deletion of disabled DSNs for safety + if @dsn.enabled? + redirect_to @dsn, alert: 'Cannot delete an enabled DSN. Please disable it first.' + return + end + + dsn_name = @dsn.name + @dsn.destroy + + redirect_to dsns_path, notice: "DSN '#{dsn_name}' was successfully deleted." + end + private def set_dsn @@ -78,18 +85,4 @@ class DsnsController < ApplicationController # Only allow admins to manage DSNs redirect_to root_path, alert: 'Access denied' unless Current.user&.admin? end - - def generate_external_dsn(key) - host = ENV.fetch("BAFFLE_HOST", "localhost:3000") - protocol = host.include?("localhost") ? "http" : "https" - "#{protocol}://#{key}@#{host}" - end - - def generate_internal_dsn(key) - internal_host = ENV.fetch("BAFFLE_INTERNAL_HOST", nil) - return nil unless internal_host.present? - - protocol = "http" # Internal connections use HTTP - "#{protocol}://#{key}@#{internal_host}" - end end \ No newline at end of file diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index ead1215..305fd16 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,6 +1,17 @@ # frozen_string_literal: true class EventsController < ApplicationController + def show + @event = Event.find(params[:id]) + @network_range = NetworkRange.contains_ip(@event.ip_address.to_s).first + + # Auto-generate network range if no match found + unless @network_range + @network_range = NetworkRangeGenerator.find_or_create_for_ip(@event.ip_address) + Rails.logger.debug "Auto-generated network range #{@network_range&.cidr} for IP #{@event.ip_address}" if @network_range + end + end + def index @events = Event.order(timestamp: :desc) Rails.logger.debug "Found #{@events.count} total events" diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb new file mode 100644 index 0000000..6486572 --- /dev/null +++ b/app/controllers/settings_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class SettingsController < ApplicationController + before_action :require_authentication + before_action :authorize_settings_management + + # GET /settings + def index + @settings = Setting.all.index_by(&:key) + end + + # PATCH /settings + def update + setting_key = params[:key] + setting_value = params[:value] + + if setting_key.present? + Setting.set(setting_key, setting_value) + redirect_to settings_path, notice: 'Setting was successfully updated.' + else + redirect_to settings_path, alert: 'Invalid setting key.' + end + end + + private + + def authorize_settings_management + # Only allow admins to manage settings + redirect_to root_path, alert: 'Access denied' unless Current.user&.admin? + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 671a85e..40b6b3b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -89,4 +89,54 @@ module ApplicationHelper raw html end + + # Helper methods for job queue status colors + def job_queue_status_color(status) + case status.to_s + when 'healthy' + 'bg-green-500' + when 'warning' + 'bg-yellow-500' + when 'critical' + 'bg-red-500' + when 'error' + 'bg-gray-500' + else + 'bg-blue-500' + end + end + + def job_queue_status_text_color(status) + case status.to_s + when 'healthy' + 'text-green-600' + when 'warning' + 'text-yellow-600' + when 'critical' + 'text-red-600' + when 'error' + 'text-gray-600' + else + 'text-blue-600' + end + end + + # Parse user agent string into readable components + def parse_user_agent(user_agent) + return nil if user_agent.blank? + + client = DeviceDetector.new(user_agent) + + { + name: client.name, + version: client.full_version, + os_name: client.os_name, + os_version: client.os_full_version, + device_type: client.device_type || "desktop", + device_name: client.device_name, + bot: client.bot?, + bot_name: client.bot_name, + raw: user_agent + } + end end diff --git a/app/javascript/controllers/dashboard_controller.js b/app/javascript/controllers/dashboard_controller.js index 78c940a..edb3ab8 100644 --- a/app/javascript/controllers/dashboard_controller.js +++ b/app/javascript/controllers/dashboard_controller.js @@ -8,7 +8,9 @@ export default class extends Controller { } connect() { - this.startRefreshing() + // TEMPORARILY DISABLED: Auto-refresh causes performance issues with slow queries (30s+ load times) + // TODO: Re-enable after optimizing analytics queries + // this.startRefreshing() } disconnect() { diff --git a/app/javascript/controllers/quick_create_rule_controller.js b/app/javascript/controllers/quick_create_rule_controller.js new file mode 100644 index 0000000..0d1bd29 --- /dev/null +++ b/app/javascript/controllers/quick_create_rule_controller.js @@ -0,0 +1,119 @@ +// QuickCreateRuleController - Handles the quick create rule form functionality +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["form", "toggle", "ruleTypeSelect", "actionSelect", "patternFields", "rateLimitFields", "redirectFields", "helpText", "conditionsField"] + + connect() { + this.setupEventListeners() + this.initializeFieldVisibility() + } + + toggle() { + this.formTarget.classList.toggle("hidden") + + if (this.formTarget.classList.contains("hidden")) { + this.resetForm() + } + } + + updateRuleTypeFields() { + if (!this.hasRuleTypeSelectTarget || !this.hasActionSelectTarget) return + + const ruleType = this.ruleTypeSelectTarget.value + const action = this.actionSelectTarget.value + + // Hide all optional fields + this.hideOptionalFields() + + // Show relevant fields based on rule type + if (["path_pattern", "header_pattern", "query_pattern", "body_signature"].includes(ruleType)) { + if (this.hasPatternFieldsTarget) { + this.patternFieldsTarget.classList.remove("hidden") + this.updatePatternHelpText(ruleType) + } + } else if (ruleType === "rate_limit") { + if (this.hasRateLimitFieldsTarget) { + this.rateLimitFieldsTarget.classList.remove("hidden") + } + } + + // Show redirect fields if action is redirect + if (action === "redirect") { + if (this.hasRedirectFieldsTarget) { + this.redirectFieldsTarget.classList.remove("hidden") + } + } + } + + updatePatternHelpText(ruleType) { + if (!this.hasHelpTextTarget || !this.hasConditionsFieldTarget) return + + const helpTexts = { + path_pattern: { + text: "Regex pattern to match URL paths (e.g.,\\.env$|wp-admin|phpmyadmin)", + placeholder: "Example: \\.env$|\\.git|config\\.php|wp-admin" + }, + header_pattern: { + text: 'JSON with header_name and pattern (e.g., {"header_name": "User-Agent", "pattern": "bot.*"})', + placeholder: 'Example: {"header_name": "User-Agent", "pattern": ".*[Bb]ot.*"}' + }, + query_pattern: { + text: "Regex pattern to match query parameters (e.g., union.*select| \ No newline at end of file diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb new file mode 100644 index 0000000..9601fec --- /dev/null +++ b/app/views/settings/index.html.erb @@ -0,0 +1,60 @@ +<% content_for :title, "Settings" %> + +
Manage system configuration and API keys
++ <% if @settings['ipapi_key']&.value.present? %> + โ Configured in database + <% elsif ENV['IPAPI_KEY'].present? %> + Using environment variable (IPAPI_KEY) + <% else %> + ipapi.is not active + <% end %> +
++ Get your API key from ipapi.is +
+More configuration options will be added here as needed.
+