Lots of updates
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
31
app/controllers/settings_controller.rb
Normal file
31
app/controllers/settings_controller.rb
Normal 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
|
||||
Reference in New Issue
Block a user