Lots of updates

This commit is contained in:
Dan Milne
2025-11-11 16:54:52 +11:00
parent 26216da9ca
commit cc8213f87a
41 changed files with 1463 additions and 614 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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