Tidy up homepage and navigation
This commit is contained in:
2
app/assets/builds/tailwind.css
Normal file
2
app/assets/builds/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
133
app/controllers/analytics_controller.rb
Normal file
133
app/controllers/analytics_controller.rb
Normal file
@@ -0,0 +1,133 @@
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
|
||||
# Event breakdown by action
|
||||
@event_breakdown = 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
|
||||
|
||||
# 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 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)
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# Recent activity
|
||||
@recent_events = Event.recent.limit(10)
|
||||
@recent_rules = Rule.order(created_at: :desc).limit(5)
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# Prepare data for charts
|
||||
@chart_data = prepare_chart_data
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_start_time(period)
|
||||
case period
|
||||
when :hour
|
||||
1.hour.ago
|
||||
when :day
|
||||
24.hours.ago
|
||||
when :week
|
||||
1.week.ago
|
||||
when :month
|
||||
1.month.ago
|
||||
else
|
||||
24.hours.ago
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_chart_data
|
||||
# Events over time (hourly buckets for last 24 hours)
|
||||
events_by_hour = Event.where("timestamp >= ?", 24.hours.ago)
|
||||
.group("DATE_TRUNC('hour', timestamp)")
|
||||
.count
|
||||
|
||||
# Convert to chart format
|
||||
timeline_data = (0..23).map do |hour_ago|
|
||||
hour_time = hour_ago.hours.ago
|
||||
hour_key = hour_time.strftime("%Y-%m-%d %H:00:00")
|
||||
{
|
||||
time: hour_time.strftime("%H:00"),
|
||||
total: events_by_hour[hour_key] || 0
|
||||
}
|
||||
end.reverse
|
||||
|
||||
# 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
|
||||
end
|
||||
@@ -4,17 +4,16 @@ class Api::EventsController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token
|
||||
allow_unauthenticated_access # Skip normal session auth, use DSN auth instead
|
||||
|
||||
# POST /api/:project_id/events
|
||||
# POST /api/events
|
||||
def create
|
||||
project = authenticate_project!
|
||||
return head :not_found unless project
|
||||
dsn = authenticate_dsn!
|
||||
return head :not_found unless dsn
|
||||
|
||||
# Parse the incoming WAF event data
|
||||
event_data = parse_event_data(request)
|
||||
|
||||
# Create event asynchronously
|
||||
ProcessWafEventJob.perform_later(
|
||||
project_id: project.id,
|
||||
event_data: event_data,
|
||||
headers: extract_serializable_headers(request)
|
||||
)
|
||||
@@ -64,8 +63,8 @@ class Api::EventsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def authenticate_project!
|
||||
DsnAuthenticationService.authenticate(request, params[:project_id])
|
||||
def authenticate_dsn!
|
||||
DsnAuthenticationService.authenticate(request)
|
||||
end
|
||||
|
||||
def parse_event_data(request)
|
||||
|
||||
@@ -7,11 +7,11 @@ module Api
|
||||
# These endpoints are kept for administrative/debugging purposes only
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
allow_unauthenticated_access # Skip normal session auth, use project key auth instead
|
||||
before_action :authenticate_project!
|
||||
before_action :check_project_enabled
|
||||
allow_unauthenticated_access # Skip normal session auth, use DSN auth instead
|
||||
before_action :authenticate_dsn!
|
||||
before_action :check_dsn_enabled
|
||||
|
||||
# GET /api/:public_key/rules/version
|
||||
# GET /api/rules/version
|
||||
# Quick version check - returns latest updated_at timestamp
|
||||
def version
|
||||
current_sampling = HubLoad.current_sampling
|
||||
@@ -24,9 +24,9 @@ module Api
|
||||
}
|
||||
end
|
||||
|
||||
# GET /api/:public_key/rules?since=1730646186
|
||||
# GET /api/rules?since=1730646186
|
||||
# Incremental sync - returns rules updated since timestamp (Unix timestamp in seconds)
|
||||
# GET /api/:public_key/rules
|
||||
# GET /api/rules
|
||||
# Full sync - returns all active rules
|
||||
def index
|
||||
rules = if params[:since].present?
|
||||
@@ -52,20 +52,20 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def authenticate_project!
|
||||
public_key = params[:public_key] || params[:project_id]
|
||||
def authenticate_dsn!
|
||||
@dsn = DsnAuthenticationService.authenticate(request)
|
||||
|
||||
@project = Project.find_by(public_key: public_key)
|
||||
|
||||
unless @project
|
||||
render json: { error: "Invalid project key" }, status: :unauthorized
|
||||
unless @dsn
|
||||
render json: { error: "Invalid DSN key" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
rescue DsnAuthenticationService::AuthenticationError => e
|
||||
render json: { error: e.message }, status: :unauthorized
|
||||
end
|
||||
|
||||
def check_project_enabled
|
||||
unless @project.enabled?
|
||||
render json: { error: "Project is disabled" }, status: :forbidden
|
||||
def check_dsn_enabled
|
||||
unless @dsn.enabled?
|
||||
render json: { error: "DSN is disabled" }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
95
app/controllers/dsns_controller.rb
Normal file
95
app/controllers/dsns_controller.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DsnsController < ApplicationController
|
||||
before_action :require_authentication
|
||||
before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable]
|
||||
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
|
||||
def new
|
||||
authorize Dsn
|
||||
@dsn = Dsn.new
|
||||
end
|
||||
|
||||
# POST /dsns
|
||||
def create
|
||||
authorize Dsn
|
||||
@dsn = Dsn.new(dsn_params)
|
||||
|
||||
if @dsn.save
|
||||
redirect_to @dsn, notice: 'DSN was successfully created.'
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# GET /dsns/:id
|
||||
def show
|
||||
end
|
||||
|
||||
# GET /dsns/:id/edit
|
||||
def edit
|
||||
end
|
||||
|
||||
# PATCH/PUT /dsns/:id
|
||||
def update
|
||||
if @dsn.update(dsn_params)
|
||||
redirect_to @dsn, notice: 'DSN was successfully updated.'
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# POST /dsns/:id/disable
|
||||
def disable
|
||||
@dsn.update!(enabled: false)
|
||||
redirect_to @dsn, notice: 'DSN was disabled.'
|
||||
end
|
||||
|
||||
# POST /dsns/:id/enable
|
||||
def enable
|
||||
@dsn.update!(enabled: true)
|
||||
redirect_to @dsn, notice: 'DSN was enabled.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_dsn
|
||||
@dsn = Dsn.find(params[:id])
|
||||
end
|
||||
|
||||
def dsn_params
|
||||
params.require(:dsn).permit(:name, :enabled)
|
||||
end
|
||||
|
||||
def authorize_dsn_management
|
||||
# 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,21 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EventsController < ApplicationController
|
||||
before_action :set_project
|
||||
|
||||
def index
|
||||
@events = @project.events.order(timestamp: :desc)
|
||||
Rails.logger.debug "Found project? #{@project.name} / #{@project.events.count} / #{@events.count}"
|
||||
@events = Event.order(timestamp: :desc)
|
||||
Rails.logger.debug "Found #{@events.count} total events"
|
||||
Rails.logger.debug "Action: #{params[:waf_action]}"
|
||||
|
||||
# Apply filters
|
||||
@events = @events.by_ip(params[:ip]) if params[:ip].present?
|
||||
@events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present?
|
||||
@events = @events.where(country_code: params[:country]) if params[:country].present?
|
||||
|
||||
Rails.logger.debug "after filter #{@project.name} / #{@project.events.count} / #{@events.count}"
|
||||
Rails.logger.debug "Events count after filtering: #{@events.count}"
|
||||
|
||||
# Debug info
|
||||
Rails.logger.debug "Events count before pagination: #{@events.count}"
|
||||
Rails.logger.debug "Project: #{@project&.name} (ID: #{@project&.id})"
|
||||
|
||||
# Paginate
|
||||
@pagy, @events = pagy(@events, items: 50)
|
||||
@@ -23,11 +22,4 @@ class EventsController < ApplicationController
|
||||
Rails.logger.debug "Events count after pagination: #{@events.count}"
|
||||
Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_project
|
||||
@project = Project.find(params[:project_id]) || Project.find_by(slug: params[:project_id])
|
||||
redirect_to projects_path, alert: "Project not found" unless @project
|
||||
end
|
||||
end
|
||||
@@ -7,11 +7,10 @@
|
||||
class NetworkRangesController < ApplicationController
|
||||
# Follow proper before_action order:
|
||||
# 1. Authentication/Authorization
|
||||
allow_unauthenticated_access only: [:index, :show, :lookup]
|
||||
# All actions require authentication
|
||||
|
||||
# 2. Resource loading
|
||||
before_action :set_network_range, only: [:show, :edit, :update, :destroy, :enrich]
|
||||
before_action :set_project, only: [:index, :show]
|
||||
|
||||
# GET /network_ranges
|
||||
def index
|
||||
@@ -158,15 +157,6 @@ class NetworkRangesController < ApplicationController
|
||||
@network_range = NetworkRange.find_by!(network: cidr)
|
||||
end
|
||||
|
||||
def set_project
|
||||
# For now, use the first project or create a default one
|
||||
@project = Project.first || Project.create!(
|
||||
name: 'Default Project',
|
||||
slug: 'default',
|
||||
public_key: SecureRandom.hex(32)
|
||||
)
|
||||
end
|
||||
|
||||
def network_range_params
|
||||
params.require(:network_range).permit(
|
||||
:network,
|
||||
@@ -204,18 +194,33 @@ class NetworkRangesController < ApplicationController
|
||||
end
|
||||
|
||||
def calculate_traffic_stats(network_range)
|
||||
# Calculate traffic statistics for this network range
|
||||
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("network_ranges.id = ?", network_range.id)
|
||||
# Use the cached events_count for total requests (much more performant)
|
||||
# For detailed breakdown, we still need to query but we can optimize with a limit
|
||||
if network_range.events_count > 0
|
||||
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("network_ranges.id = ?", network_range.id)
|
||||
.limit(1000) # Limit the sample for performance
|
||||
|
||||
{
|
||||
total_requests: events.count,
|
||||
unique_ips: events.distinct.count(:ip_address),
|
||||
blocked_requests: events.blocked.count,
|
||||
allowed_requests: events.allowed.count,
|
||||
top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10),
|
||||
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||
recent_activity: events.recent.limit(20)
|
||||
}
|
||||
{
|
||||
total_requests: network_range.events_count, # Use cached count
|
||||
unique_ips: events.distinct.count(:ip_address),
|
||||
blocked_requests: events.blocked.count,
|
||||
allowed_requests: events.allowed.count,
|
||||
top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10),
|
||||
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||
recent_activity: events.recent.limit(20)
|
||||
}
|
||||
else
|
||||
# No events - return empty stats
|
||||
{
|
||||
total_requests: 0,
|
||||
unique_ips: 0,
|
||||
blocked_requests: 0,
|
||||
allowed_requests: 0,
|
||||
top_paths: {},
|
||||
top_user_agents: {},
|
||||
recent_activity: []
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,99 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProjectsController < ApplicationController
|
||||
before_action :set_project, only: [:show, :edit, :update, :events, :analytics]
|
||||
|
||||
def index
|
||||
@projects = Project.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def show
|
||||
@recent_events = @project.recent_events(limit: 10)
|
||||
@event_count = @project.event_count(24.hours.ago)
|
||||
@blocked_count = @project.blocked_count(24.hours.ago)
|
||||
@waf_status = @project.waf_status
|
||||
end
|
||||
|
||||
def new
|
||||
@project = Project.new
|
||||
end
|
||||
|
||||
def create
|
||||
@project = Project.new(project_params)
|
||||
|
||||
if @project.save
|
||||
redirect_to @project, notice: "Project was successfully created. Use this DSN for your baffle-agent: #{@project.dsn}"
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @project.update(project_params)
|
||||
redirect_to @project, notice: "Project was successfully updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def events
|
||||
@events = @project.events.recent.includes(:project)
|
||||
|
||||
# Apply filters
|
||||
@events = @events.by_ip(params[:ip]) if params[:ip].present?
|
||||
@events = @events.by_waf_action(params[:action]) if params[:action].present?
|
||||
@events = @events.where(country_code: params[:country]) if params[:country].present?
|
||||
|
||||
# Debug info
|
||||
Rails.logger.debug "Events count before pagination: #{@events.count}"
|
||||
Rails.logger.debug "Project: #{@project&.name} (ID: #{@project&.id})"
|
||||
|
||||
# Paginate
|
||||
@pagy, @events = pagy(@events, items: 50)
|
||||
|
||||
Rails.logger.debug "Events count after pagination: #{@events.count}"
|
||||
Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages"
|
||||
end
|
||||
|
||||
def analytics
|
||||
@time_range = params[:time_range]&.to_i || 24 # hours
|
||||
|
||||
# Basic analytics
|
||||
@total_events = @project.event_count(@time_range.hours.ago)
|
||||
@blocked_events = @project.blocked_count(@time_range.hours.ago)
|
||||
@allowed_events = @project.allowed_count(@time_range.hours.ago)
|
||||
|
||||
# Top blocked IPs
|
||||
@top_blocked_ips = @project.top_blocked_ips(limit: 10, time_range: @time_range.hours.ago)
|
||||
|
||||
# Country distribution
|
||||
@country_stats = @project.events
|
||||
.where(timestamp: @time_range.hours.ago..Time.current)
|
||||
.where.not(country_code: nil)
|
||||
.group(:country_code)
|
||||
.select('country_code, COUNT(*) as count')
|
||||
.order('count DESC')
|
||||
.limit(10)
|
||||
|
||||
# Action distribution
|
||||
@action_stats = @project.events
|
||||
.where(timestamp: @time_range.hours.ago..Time.current)
|
||||
.group(:waf_action)
|
||||
.select('waf_action as action, COUNT(*) as count')
|
||||
.order('count DESC')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_project
|
||||
@project = Project.find_by(slug: params[:id]) || Project.find_by(id: params[:id])
|
||||
redirect_to projects_path, alert: "Project not found" unless @project
|
||||
end
|
||||
|
||||
def project_params
|
||||
params.require(:project).permit(:name, :enabled, settings: {})
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
class RegistrationsController < ApplicationController
|
||||
layout "authentication"
|
||||
allow_unauthenticated_access only: [:new, :create]
|
||||
before_action :ensure_no_users_exist, only: [:new, :create]
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
class RulesController < ApplicationController
|
||||
# Follow proper before_action order:
|
||||
# 1. Authentication/Authorization
|
||||
allow_unauthenticated_access only: [:index, :show]
|
||||
# All actions require authentication
|
||||
|
||||
# 2. Resource loading
|
||||
before_action :set_rule, only: [:show, :edit, :update, :disable, :enable]
|
||||
before_action :set_project, only: [:index, :show]
|
||||
|
||||
# GET /rules
|
||||
def index
|
||||
@rules = policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc)
|
||||
@pagy, @rules = pagy(policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc))
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
end
|
||||
@@ -43,6 +42,9 @@ class RulesController < ApplicationController
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
|
||||
# Process additional form data for quick create
|
||||
process_quick_create_parameters
|
||||
|
||||
# Handle network range creation if CIDR is provided
|
||||
if params[:cidr].present? && @rule.network_rule?
|
||||
network_range = NetworkRange.find_or_create_by(cidr: params[:cidr]) do |range|
|
||||
@@ -53,8 +55,17 @@ class RulesController < ApplicationController
|
||||
@rule.network_range = network_range
|
||||
end
|
||||
|
||||
# Calculate priority automatically based on rule type
|
||||
calculate_rule_priority
|
||||
|
||||
if @rule.save
|
||||
redirect_to @rule, notice: 'Rule was successfully created.'
|
||||
# For quick create from NetworkRange page, redirect back to network range
|
||||
if params[:rule][:network_range_id].present? && request.referer&.include?('/network_ranges/')
|
||||
network_range = NetworkRange.find(params[:rule][:network_range_id])
|
||||
redirect_to network_range, notice: 'Rule was successfully created.'
|
||||
else
|
||||
redirect_to @rule, notice: 'Rule was successfully created.'
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -122,13 +133,236 @@ class RulesController < ApplicationController
|
||||
params.require(:rule).permit(permitted)
|
||||
end
|
||||
|
||||
def set_project
|
||||
# For now, use the first project or create a default one
|
||||
@project = Project.first || Project.create!(
|
||||
name: 'Default Project',
|
||||
slug: 'default',
|
||||
public_key: SecureRandom.hex(32)
|
||||
)
|
||||
def calculate_rule_priority
|
||||
return unless @rule
|
||||
|
||||
case @rule.rule_type
|
||||
when 'network'
|
||||
# For network rules, priority based on prefix specificity
|
||||
if @rule.network_range
|
||||
prefix = @rule.network_range.prefix_length
|
||||
@rule.priority = case prefix
|
||||
when 32 then 200 # /32 single IP
|
||||
when 31 then 190
|
||||
when 30 then 180
|
||||
when 29 then 170
|
||||
when 28 then 160
|
||||
when 27 then 150
|
||||
when 26 then 140
|
||||
when 25 then 130
|
||||
when 24 then 120
|
||||
when 23 then 110
|
||||
when 22 then 100
|
||||
when 21 then 90
|
||||
when 20 then 80
|
||||
when 19 then 70
|
||||
when 18 then 60
|
||||
when 17 then 50
|
||||
when 16 then 40
|
||||
when 15 then 30
|
||||
when 14 then 20
|
||||
when 13 then 10
|
||||
else 0
|
||||
end
|
||||
else
|
||||
@rule.priority = 100 # Default for network rules without range
|
||||
end
|
||||
when 'protocol_violation'
|
||||
@rule.priority = 95
|
||||
when 'method_enforcement'
|
||||
@rule.priority = 90
|
||||
when 'path_pattern'
|
||||
@rule.priority = 85
|
||||
when 'header_pattern', 'query_pattern'
|
||||
@rule.priority = 80
|
||||
when 'body_signature'
|
||||
@rule.priority = 75
|
||||
when 'rate_limit'
|
||||
@rule.priority = 70
|
||||
when 'composite'
|
||||
@rule.priority = 65
|
||||
else
|
||||
@rule.priority = 50 # Default priority
|
||||
end
|
||||
end
|
||||
|
||||
def process_quick_create_parameters
|
||||
return unless @rule
|
||||
|
||||
# Handle rate limiting parameters
|
||||
if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present?
|
||||
rate_limit_data = {
|
||||
limit: params[:rate_limit].to_i,
|
||||
window_seconds: params[:rate_window].to_i,
|
||||
scope: 'per_ip'
|
||||
}
|
||||
|
||||
# Update conditions with rate limit data
|
||||
@rule.conditions ||= {}
|
||||
@rule.conditions.merge!(rate_limit_data)
|
||||
end
|
||||
|
||||
end
|
||||
# Handle redirect URL
|
||||
if @rule.action == 'redirect' && params[:redirect_url].present?
|
||||
@rule.metadata ||= {}
|
||||
if @rule.metadata.is_a?(String)
|
||||
begin
|
||||
@rule.metadata = JSON.parse(@rule.metadata)
|
||||
rescue JSON::ParserError
|
||||
@rule.metadata = {}
|
||||
end
|
||||
end
|
||||
@rule.metadata.merge!({
|
||||
redirect_url: params[:redirect_url],
|
||||
redirect_status: 302
|
||||
})
|
||||
end
|
||||
|
||||
# Parse metadata if it's a string that looks like JSON
|
||||
if @rule.metadata.is_a?(String) && @rule.metadata.starts_with?('{')
|
||||
begin
|
||||
@rule.metadata = JSON.parse(@rule.metadata)
|
||||
rescue JSON::ParserError
|
||||
# Keep as string if not valid JSON
|
||||
end
|
||||
end
|
||||
|
||||
# Add reason to metadata if provided
|
||||
if params.dig(:rule, :metadata).present?
|
||||
if @rule.metadata.is_a?(Hash)
|
||||
@rule.metadata['reason'] = params[:rule][:metadata]
|
||||
else
|
||||
@rule.metadata = { 'reason' => params[:rule][:metadata] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_rule
|
||||
@rule = Rule.find(params[:id])
|
||||
end
|
||||
|
||||
def rule_params
|
||||
permitted = [
|
||||
:rule_type,
|
||||
:action,
|
||||
:metadata,
|
||||
:expires_at,
|
||||
:enabled,
|
||||
:source,
|
||||
:network_range_id
|
||||
]
|
||||
|
||||
# Only include conditions for non-network rules
|
||||
if params[:rule][:rule_type] != 'network'
|
||||
permitted << :conditions
|
||||
end
|
||||
|
||||
params.require(:rule).permit(permitted)
|
||||
end
|
||||
|
||||
def calculate_rule_priority
|
||||
return unless @rule
|
||||
|
||||
case @rule.rule_type
|
||||
when 'network'
|
||||
# For network rules, priority based on prefix specificity
|
||||
if @rule.network_range
|
||||
prefix = @rule.network_range.prefix_length
|
||||
@rule.priority = case prefix
|
||||
when 32 then 200 # /32 single IP
|
||||
when 31 then 190
|
||||
when 30 then 180
|
||||
when 29 then 170
|
||||
when 28 then 160
|
||||
when 27 then 150
|
||||
when 26 then 140
|
||||
when 25 then 130
|
||||
when 24 then 120
|
||||
when 23 then 110
|
||||
when 22 then 100
|
||||
when 21 then 90
|
||||
when 20 then 80
|
||||
when 19 then 70
|
||||
when 18 then 60
|
||||
when 17 then 50
|
||||
when 16 then 40
|
||||
when 15 then 30
|
||||
when 14 then 20
|
||||
when 13 then 10
|
||||
else 0
|
||||
end
|
||||
else
|
||||
@rule.priority = 100 # Default for network rules without range
|
||||
end
|
||||
when 'protocol_violation'
|
||||
@rule.priority = 95
|
||||
when 'method_enforcement'
|
||||
@rule.priority = 90
|
||||
when 'path_pattern'
|
||||
@rule.priority = 85
|
||||
when 'header_pattern', 'query_pattern'
|
||||
@rule.priority = 80
|
||||
when 'body_signature'
|
||||
@rule.priority = 75
|
||||
when 'rate_limit'
|
||||
@rule.priority = 70
|
||||
when 'composite'
|
||||
@rule.priority = 65
|
||||
else
|
||||
@rule.priority = 50 # Default priority
|
||||
end
|
||||
end
|
||||
|
||||
def process_quick_create_parameters
|
||||
return unless @rule
|
||||
|
||||
# Handle rate limiting parameters
|
||||
if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present?
|
||||
rate_limit_data = {
|
||||
limit: params[:rate_limit].to_i,
|
||||
window_seconds: params[:rate_window].to_i,
|
||||
scope: 'per_ip'
|
||||
}
|
||||
|
||||
# Update conditions with rate limit data
|
||||
@rule.conditions ||= {}
|
||||
@rule.conditions.merge!(rate_limit_data)
|
||||
end
|
||||
|
||||
# Handle redirect URL
|
||||
if @rule.action == 'redirect' && params[:redirect_url].present?
|
||||
@rule.metadata ||= {}
|
||||
if @rule.metadata.is_a?(String)
|
||||
begin
|
||||
@rule.metadata = JSON.parse(@rule.metadata)
|
||||
rescue JSON::ParserError
|
||||
@rule.metadata = {}
|
||||
end
|
||||
end
|
||||
@rule.metadata.merge!({
|
||||
redirect_url: params[:redirect_url],
|
||||
redirect_status: 302
|
||||
})
|
||||
end
|
||||
|
||||
# Parse metadata if it's a string that looks like JSON
|
||||
if @rule.metadata.is_a?(String) && @rule.metadata.starts_with?('{')
|
||||
begin
|
||||
@rule.metadata = JSON.parse(@rule.metadata)
|
||||
rescue JSON::ParserError
|
||||
# Keep as string if not valid JSON
|
||||
end
|
||||
end
|
||||
|
||||
# Add reason to metadata if provided
|
||||
if params.dig(:rule, :metadata).present?
|
||||
if @rule.metadata.is_a?(Hash)
|
||||
@rule.metadata['reason'] = params[:rule][:metadata]
|
||||
else
|
||||
@rule.metadata = { 'reason' => params[:rule][:metadata] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
class SessionsController < ApplicationController
|
||||
layout "authentication"
|
||||
allow_unauthenticated_access only: %i[ new create ]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
|
||||
|
||||
|
||||
@@ -1,3 +1,92 @@
|
||||
module ApplicationHelper
|
||||
include Pagy::Frontend if defined?(Pagy)
|
||||
|
||||
# Helper method for time period selector styling
|
||||
def time_period_class(period)
|
||||
base_classes = "px-4 py-2 text-sm font-medium border-r border-gray-300 last:border-r-0"
|
||||
|
||||
if @time_period == period
|
||||
base_classes + " bg-blue-600 text-white"
|
||||
else
|
||||
base_classes + " text-gray-700 hover:bg-gray-50"
|
||||
end
|
||||
end
|
||||
|
||||
# Custom pagination with Tailwind CSS styling
|
||||
def pagy_nav_tailwind(pagy, pagy_id: nil)
|
||||
return '' if pagy.pages <= 1
|
||||
|
||||
html = '<nav class="flex items-center justify-between" aria-label="Pagination">'
|
||||
html += '<div class="flex-1 flex justify-between sm:hidden">'
|
||||
|
||||
if pagy.prev
|
||||
html += link_to('← Previous', pagy_url_for(pagy, pagy.prev),
|
||||
class: 'relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50')
|
||||
else
|
||||
html += '<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-100 cursor-not-allowed">← Previous</span>'
|
||||
end
|
||||
|
||||
if pagy.next
|
||||
html += link_to('Next →', pagy_url_for(pagy, pagy.next),
|
||||
class: 'ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50')
|
||||
else
|
||||
html += '<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-100 cursor-not-allowed">Next →</span>'
|
||||
end
|
||||
|
||||
html += '</div>'
|
||||
html += '<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">'
|
||||
html += '<div>'
|
||||
html += '<p class="text-sm text-gray-700">'
|
||||
html += 'Showing'
|
||||
html += " <span class=\"font-medium\">#{pagy.from}</span>"
|
||||
html += ' to'
|
||||
html += " <span class=\"font-medium\">#{pagy.to}</span>"
|
||||
html += ' of'
|
||||
html += " <span class=\"font-medium\">#{pagy.count}</span>"
|
||||
html += ' results'
|
||||
html += '</p>'
|
||||
html += '</div>'
|
||||
html += '<div>'
|
||||
html += '<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">'
|
||||
|
||||
# Previous button
|
||||
if pagy.prev
|
||||
html += link_to('←', pagy_url_for(pagy, pagy.prev),
|
||||
class: 'relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50')
|
||||
else
|
||||
html += '<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">←</span>'
|
||||
end
|
||||
|
||||
# Page numbers
|
||||
pagy.series.each do |item|
|
||||
case item
|
||||
when Integer
|
||||
if item == pagy.page
|
||||
html += "<span aria-current=\"page\" class=\"relative inline-flex items-center px-4 py-2 border border-blue-500 bg-blue-500 text-sm font-medium text-white\">#{item}</span>"
|
||||
else
|
||||
html += link_to(item, pagy_url_for(pagy, item),
|
||||
class: 'relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50')
|
||||
end
|
||||
when String
|
||||
html += "<span class=\"relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700\">#{item}</span>"
|
||||
when :gap
|
||||
html += "<span class=\"relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700\">...</span>"
|
||||
end
|
||||
end
|
||||
|
||||
# Next button
|
||||
if pagy.next
|
||||
html += link_to('→', pagy_url_for(pagy, pagy.next),
|
||||
class: 'relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50')
|
||||
else
|
||||
html += '<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">→</span>'
|
||||
end
|
||||
|
||||
html += '</nav>'
|
||||
html += '</div>'
|
||||
html += '</div>'
|
||||
html += '</nav>'
|
||||
|
||||
raw html
|
||||
end
|
||||
end
|
||||
|
||||
33
app/helpers/navigation_helper.rb
Normal file
33
app/helpers/navigation_helper.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module NavigationHelper
|
||||
def nav_link_class(path)
|
||||
current = request.path == path || (path == root_path && request.path == events_path && !request.path.include?('/network_ranges') && !request.path.include?('/rules'))
|
||||
|
||||
if current
|
||||
"bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||
else
|
||||
"text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
end
|
||||
end
|
||||
|
||||
def mobile_nav_link_class(path)
|
||||
current = request.path == path || (path == root_path && request.path == events_path && !request.path.include?('/network_ranges') && !request.path.include?('/rules'))
|
||||
|
||||
if current
|
||||
"bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||
else
|
||||
"text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium transition-colors"
|
||||
end
|
||||
end
|
||||
|
||||
def time_period_class(period)
|
||||
base_class = "px-4 py-2 text-sm font-medium border-r last:border-r-0 transition-colors"
|
||||
|
||||
if @time_period == period
|
||||
base_class + " bg-blue-600 text-white"
|
||||
else
|
||||
base_class + " bg-white text-gray-700 hover:bg-gray-50"
|
||||
end
|
||||
end
|
||||
end
|
||||
51
app/javascript/controllers/dashboard_controller.js
Normal file
51
app/javascript/controllers/dashboard_controller.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["eventsCount", "rulesCount", "networkRangesCount", "systemHealth", "recentEvents", "topBlockedIps"]
|
||||
static values = {
|
||||
period: String,
|
||||
refreshInterval: { type: Number, default: 30000 } // 30 seconds
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.startRefreshing()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.stopRefreshing()
|
||||
}
|
||||
|
||||
startRefreshing() {
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.refreshDashboard()
|
||||
}, this.refreshIntervalValue)
|
||||
}
|
||||
|
||||
stopRefreshing() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshDashboard() {
|
||||
try {
|
||||
const response = await fetch(`/analytics?period=${this.periodValue}`, {
|
||||
headers: {
|
||||
"Accept": "text/vnd.turbo-stream.html"
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
Turbo.renderStreamMessage(html)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh dashboard:", error)
|
||||
}
|
||||
}
|
||||
|
||||
periodChanged(event) {
|
||||
this.periodValue = event.currentTarget.dataset.period
|
||||
this.refreshDashboard()
|
||||
}
|
||||
}
|
||||
29
app/javascript/controllers/dropdown_controller.js
Normal file
29
app/javascript/controllers/dropdown_controller.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["menu"]
|
||||
|
||||
connect() {
|
||||
// Add click outside listener to close dropdown
|
||||
this.boundHide = this.hide.bind(this)
|
||||
document.addEventListener("click", this.boundHide)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("click", this.boundHide)
|
||||
}
|
||||
|
||||
toggle(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
this.menuTarget.classList.toggle("hidden")
|
||||
}
|
||||
|
||||
hide(event) {
|
||||
// Don't hide if clicking inside the dropdown
|
||||
if (this.element.contains(event.target)) return
|
||||
|
||||
this.menuTarget.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
17
app/javascript/controllers/mobile_menu_controller.js
Normal file
17
app/javascript/controllers/mobile_menu_controller.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["menu", "open", "close"]
|
||||
|
||||
toggle(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const menu = this.menuTarget
|
||||
const openIcon = this.openTarget
|
||||
const closeIcon = this.closeTarget
|
||||
|
||||
menu.classList.toggle("hidden")
|
||||
openIcon.classList.toggle("hidden")
|
||||
closeIcon.classList.toggle("hidden")
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ class EventNormalizationJob < ApplicationJob
|
||||
events = Event.where(request_host_id: nil)
|
||||
.limit(batch_size)
|
||||
.offset(offset)
|
||||
.includes(:project)
|
||||
|
||||
break if events.empty?
|
||||
|
||||
|
||||
@@ -3,21 +3,20 @@
|
||||
class GenerateWafRulesJob < ApplicationJob
|
||||
queue_as :waf_rules
|
||||
|
||||
def perform(project_id:, event_id:)
|
||||
project = Project.find(project_id)
|
||||
def perform(event_id:)
|
||||
event = Event.find(event_id)
|
||||
|
||||
# Only analyze blocked events for rule generation
|
||||
return unless event.blocked?
|
||||
|
||||
# Generate different types of rules based on patterns
|
||||
generate_ip_rules(project, event)
|
||||
generate_path_rules(project, event)
|
||||
generate_user_agent_rules(project, event)
|
||||
generate_parameter_rules(project, event)
|
||||
generate_ip_rules(event)
|
||||
generate_path_rules(event)
|
||||
generate_user_agent_rules(event)
|
||||
generate_parameter_rules(event)
|
||||
|
||||
# Notify project of new rules
|
||||
project.broadcast_rules_refresh
|
||||
# Broadcast rule updates globally
|
||||
ActionCable.server.broadcast("rules", { type: "refresh" })
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Error generating WAF rules: #{e.message}"
|
||||
@@ -26,30 +25,23 @@ class GenerateWafRulesJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def generate_ip_rules(project, event)
|
||||
def generate_ip_rules(event)
|
||||
return unless event.ip_address.present?
|
||||
|
||||
# Check if this IP has multiple violations
|
||||
violation_count = project.events
|
||||
violation_count = Event
|
||||
.by_ip(event.ip_address)
|
||||
.blocked
|
||||
.where(timestamp: 24.hours.ago..Time.current)
|
||||
.count
|
||||
|
||||
# Auto-block IPs with 10+ violations in 24 hours
|
||||
if violation_count >= 10 && !project.blocked_ips.include?(event.ip_address)
|
||||
project.add_ip_rule(
|
||||
event.ip_address,
|
||||
'block',
|
||||
expires_at: 7.days.from_now,
|
||||
reason: "Auto-generated: #{violation_count} violations in 24 hours"
|
||||
)
|
||||
|
||||
Rails.logger.info "Auto-blocked IP #{event.ip_address} for project #{project.slug}"
|
||||
# Log high-violation IPs - no automatic blocking without projects
|
||||
if violation_count >= 10
|
||||
Rails.logger.info "IP with high violation count: #{event.ip_address} (#{violation_count} violations in 24 hours)"
|
||||
end
|
||||
end
|
||||
|
||||
def generate_path_rules(project, event)
|
||||
def generate_path_rules(event)
|
||||
return unless event.request_path.present?
|
||||
|
||||
# Look for repeated attack patterns on specific paths
|
||||
@@ -65,7 +57,7 @@ class GenerateWafRulesJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def generate_user_agent_rules(project, event)
|
||||
def generate_user_agent_rules(event)
|
||||
return unless event.user_agent.present?
|
||||
|
||||
# Look for malicious user agents
|
||||
@@ -81,7 +73,7 @@ class GenerateWafRulesJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def generate_parameter_rules(project, event)
|
||||
def generate_parameter_rules(event)
|
||||
params = event.query_params
|
||||
return unless params.present?
|
||||
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
class ProcessWafAnalyticsJob < ApplicationJob
|
||||
queue_as :waf_analytics
|
||||
|
||||
def perform(project_id:, event_id:)
|
||||
project = Project.find(project_id)
|
||||
def perform(event_id:)
|
||||
event = Event.find(event_id)
|
||||
|
||||
# Analyze event patterns
|
||||
analyze_traffic_patterns(project, event)
|
||||
analyze_geographic_distribution(project, event)
|
||||
analyze_attack_vectors(project, event)
|
||||
analyze_traffic_patterns(event)
|
||||
analyze_geographic_distribution(event)
|
||||
analyze_attack_vectors(event)
|
||||
|
||||
# Update project analytics cache
|
||||
update_project_analytics(project)
|
||||
# Update global analytics cache
|
||||
update_analytics_cache
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Error processing WAF analytics: #{e.message}"
|
||||
@@ -22,14 +21,15 @@ class ProcessWafAnalyticsJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def analyze_traffic_patterns(project, event)
|
||||
def analyze_traffic_patterns(event)
|
||||
# Look for unusual traffic spikes
|
||||
recent_events = project.events.where(timestamp: 5.minutes.ago..Time.current)
|
||||
recent_events = Event.where(timestamp: 5.minutes.ago..Time.current)
|
||||
|
||||
if recent_events.count > project.rate_limit_threshold * 5
|
||||
# Use a default threshold since we no longer have project-specific thresholds
|
||||
threshold = 1000 # Default threshold
|
||||
if recent_events.count > threshold * 5
|
||||
# High traffic detected - create an issue
|
||||
Issue.create!(
|
||||
project: project,
|
||||
title: "High Traffic Spike Detected",
|
||||
description: "Detected #{recent_events.count} requests in the last 5 minutes",
|
||||
severity: "medium",
|
||||
@@ -37,56 +37,51 @@ class ProcessWafAnalyticsJob < ApplicationJob
|
||||
metadata: {
|
||||
event_count: recent_events.count,
|
||||
time_window: "5 minutes",
|
||||
threshold: project.rate_limit_threshold * 5
|
||||
threshold: threshold * 5
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_geographic_distribution(project, event)
|
||||
def analyze_geographic_distribution(event)
|
||||
return unless event.country_code.present?
|
||||
|
||||
# Check if this country is unusual for this project
|
||||
country_events = project.events
|
||||
# Check if this country is unusual globally
|
||||
country_events = Event
|
||||
.where(country_code: event.country_code)
|
||||
.where(timestamp: 1.hour.ago..Time.current)
|
||||
|
||||
# If this is the first event from this country or unusual spike
|
||||
if country_events.count == 1 || country_events.count > 100
|
||||
Rails.logger.info "Unusual geographic activity from #{event.country_code} for project #{project.slug}"
|
||||
Rails.logger.info "Unusual geographic activity from #{event.country_code}"
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_attack_vectors(project, event)
|
||||
def analyze_attack_vectors(event)
|
||||
return unless event.blocked?
|
||||
|
||||
# Analyze common attack patterns
|
||||
analyze_ip_reputation(project, event)
|
||||
analyze_user_agent_patterns(project, event)
|
||||
analyze_path_attacks(project, event)
|
||||
analyze_ip_reputation(event)
|
||||
analyze_user_agent_patterns(event)
|
||||
analyze_path_attacks(event)
|
||||
end
|
||||
|
||||
def analyze_ip_reputation(project, event)
|
||||
def analyze_ip_reputation(event)
|
||||
return unless event.ip_address.present?
|
||||
|
||||
# Count recent blocks from this IP
|
||||
recent_blocks = project.events
|
||||
recent_blocks = Event
|
||||
.by_ip(event.ip_address)
|
||||
.blocked
|
||||
.where(timestamp: 1.hour.ago..Time.current)
|
||||
|
||||
if recent_blocks.count >= 5
|
||||
# Suggest automatic IP block
|
||||
project.add_ip_rule(
|
||||
event.ip_address,
|
||||
'block',
|
||||
expires_at: 24.hours.from_now,
|
||||
reason: "Automated block: #{recent_blocks.count} violations in 1 hour"
|
||||
)
|
||||
# Log IP reputation issue - no automatic IP blocking without projects
|
||||
Rails.logger.warn "IP with poor reputation detected: #{event.ip_address} (#{recent_blocks.count} blocks in 1 hour)"
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_user_agent_patterns(project, event)
|
||||
def analyze_user_agent_patterns(event)
|
||||
return unless event.user_agent.present?
|
||||
|
||||
# Look for common bot/user agent patterns
|
||||
@@ -101,7 +96,7 @@ class ProcessWafAnalyticsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_path_attacks(project, event)
|
||||
def analyze_path_attacks(event)
|
||||
return unless event.request_path.present?
|
||||
|
||||
# Look for common attack paths
|
||||
@@ -119,8 +114,8 @@ class ProcessWafAnalyticsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def update_project_analytics(project)
|
||||
def update_analytics_cache
|
||||
# Update cached analytics for faster dashboard loading
|
||||
Rails.cache.delete("project_#{project.id}_analytics")
|
||||
Rails.cache.delete("global_analytics")
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,7 @@
|
||||
class ProcessWafEventJob < ApplicationJob
|
||||
queue_as :waf_events
|
||||
|
||||
def perform(project_id:, event_data:, headers:)
|
||||
project = Project.find(project_id)
|
||||
|
||||
def perform(event_data:, headers:)
|
||||
# Handle both single event and events array
|
||||
events_to_process = []
|
||||
|
||||
@@ -26,7 +24,7 @@ class ProcessWafEventJob < ApplicationJob
|
||||
event_id = single_event_data['event_id'] || SecureRandom.uuid
|
||||
|
||||
# Create the WAF event record
|
||||
event = Event.create_from_waf_payload!(event_id, single_event_data, project)
|
||||
event = Event.create_from_waf_payload!(event_id, single_event_data)
|
||||
|
||||
# Enrich with geo-location data if missing
|
||||
if event.ip_address.present? && event.country_code.blank?
|
||||
@@ -38,12 +36,12 @@ class ProcessWafEventJob < ApplicationJob
|
||||
end
|
||||
|
||||
# Trigger analytics processing
|
||||
ProcessWafAnalyticsJob.perform_later(project_id: project_id, event_id: event.id)
|
||||
ProcessWafAnalyticsJob.perform_later(event_id: event.id)
|
||||
|
||||
# Check for automatic rule generation opportunities
|
||||
GenerateWafRulesJob.perform_later(project_id: project_id, event_id: event.id)
|
||||
GenerateWafRulesJob.perform_later(event_id: event.id)
|
||||
|
||||
Rails.logger.info "Processed WAF event #{event_id} for project #{project.slug}"
|
||||
Rails.logger.info "Processed WAF event #{event_id}"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create WAF event: #{e.message}"
|
||||
Rails.logger.error e.record.errors.full_messages.join(", ")
|
||||
@@ -54,8 +52,8 @@ class ProcessWafEventJob < ApplicationJob
|
||||
end
|
||||
|
||||
# Broadcast real-time updates once per batch
|
||||
project.broadcast_events_refresh
|
||||
ActionCable.server.broadcast("events", { type: "refresh" })
|
||||
|
||||
Rails.logger.info "Processed #{events_to_process.count} WAF events for project #{project.slug}"
|
||||
Rails.logger.info "Processed #{events_to_process.count} WAF events"
|
||||
end
|
||||
end
|
||||
18
app/models/dsn.rb
Normal file
18
app/models/dsn.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class Dsn < ApplicationRecord
|
||||
validates :key, presence: true, uniqueness: true
|
||||
validates :name, presence: true
|
||||
|
||||
before_validation :generate_key, on: :create
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
def self.authenticate(key)
|
||||
enabled.find_by(key: key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_key
|
||||
self.key ||= SecureRandom.hex(32)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Event < ApplicationRecord
|
||||
belongs_to :project
|
||||
|
||||
# Normalized association for hosts (most valuable compression)
|
||||
belongs_to :request_host, optional: true
|
||||
|
||||
@@ -87,13 +85,12 @@ class Event < ApplicationRecord
|
||||
# Normalize event fields after extraction
|
||||
after_validation :normalize_event_fields, if: :should_normalize?
|
||||
|
||||
def self.create_from_waf_payload!(event_id, payload, project)
|
||||
def self.create_from_waf_payload!(event_id, payload)
|
||||
# Normalize headers in payload during import phase
|
||||
normalized_payload = normalize_payload_headers(payload)
|
||||
|
||||
# Create the WAF request event
|
||||
create!(
|
||||
project: project,
|
||||
event_id: event_id,
|
||||
timestamp: parse_timestamp(normalized_payload["timestamp"]),
|
||||
payload: normalized_payload,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Issue < ApplicationRecord
|
||||
belongs_to :project
|
||||
has_many :events, dependent: :nullify
|
||||
|
||||
enum :status, { open: 0, resolved: 1, ignored: 2 }
|
||||
@@ -17,18 +16,18 @@ class Issue < ApplicationRecord
|
||||
|
||||
# Real-time updates
|
||||
after_create_commit do
|
||||
broadcast_refresh_to(project)
|
||||
broadcast_refresh
|
||||
end
|
||||
|
||||
after_update_commit do
|
||||
broadcast_refresh # Refreshes the issue show page
|
||||
broadcast_refresh_to(project, "issues") # Refreshes the project's issues index
|
||||
broadcast_refresh_to("issues") # Refreshes the issues index
|
||||
end
|
||||
|
||||
def self.group_event(event_payload, project)
|
||||
def self.group_event(event_payload)
|
||||
fingerprint = generate_fingerprint(event_payload)
|
||||
|
||||
find_or_create_by(project: project, fingerprint: fingerprint) do |issue|
|
||||
find_or_create_by(fingerprint: fingerprint) do |issue|
|
||||
issue.title = extract_title(event_payload)
|
||||
issue.exception_type = extract_exception_type(event_payload)
|
||||
issue.first_seen = Time.current
|
||||
|
||||
@@ -29,6 +29,8 @@ class NetworkRange < ApplicationRecord
|
||||
scope :vpn, -> { where(is_vpn: true) }
|
||||
scope :user_created, -> { where(source: 'user_created') }
|
||||
scope :api_imported, -> { where(source: 'api_imported') }
|
||||
scope :with_events, -> { where("events_count > 0") }
|
||||
scope :most_active, -> { order(events_count: :desc) }
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_default_source
|
||||
@@ -237,9 +239,10 @@ class NetworkRange < ApplicationRecord
|
||||
cidr.to_s.gsub('/', '_')
|
||||
end
|
||||
|
||||
# Analytics methods
|
||||
# Analytics methods - events_count is now a counter cache column maintained by database triggers
|
||||
# This is much more performant than the previous implementation that did complex network queries
|
||||
def events_count
|
||||
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address]).count
|
||||
self[:events_count] || 0
|
||||
end
|
||||
|
||||
def recent_events(limit: 100)
|
||||
|
||||
8
app/policies/analytics_policy.rb
Normal file
8
app/policies/analytics_policy.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AnalyticsPolicy < ApplicationPolicy
|
||||
def index?
|
||||
# Everyone can view analytics (including unauthenticated users for monitoring)
|
||||
true
|
||||
end
|
||||
end
|
||||
49
app/policies/dsn_policy.rb
Normal file
49
app/policies/dsn_policy.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DsnPolicy < ApplicationPolicy
|
||||
def index?
|
||||
current_user.present? && current_user.admin?
|
||||
end
|
||||
|
||||
def show?
|
||||
current_user.present? && current_user.admin?
|
||||
end
|
||||
|
||||
def create?
|
||||
current_user.present? && current_user.admin?
|
||||
end
|
||||
|
||||
def new?
|
||||
create?
|
||||
end
|
||||
|
||||
def update?
|
||||
current_user.present? && current_user.admin?
|
||||
end
|
||||
|
||||
def edit?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
current_user.present? && current_user.admin?
|
||||
end
|
||||
|
||||
def disable?
|
||||
current_user.present? && current_user.admin?
|
||||
end
|
||||
|
||||
def enable?
|
||||
current_user.present? && current_user.admin?
|
||||
end
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
if user&.admin?
|
||||
scope.all
|
||||
else
|
||||
scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,24 +3,24 @@
|
||||
class DsnAuthenticationService
|
||||
class AuthenticationError < StandardError; end
|
||||
|
||||
def self.authenticate(request, project_id)
|
||||
def self.authenticate(request)
|
||||
# Try multiple authentication methods in order of preference
|
||||
|
||||
# Method 1: Query parameter authentication
|
||||
public_key = extract_key_from_query_params(request)
|
||||
return find_project(public_key, project_id) if public_key
|
||||
dsn_key = extract_key_from_query_params(request)
|
||||
return find_dsn(dsn_key) if dsn_key
|
||||
|
||||
# Method 2: X-Baffle-Auth header (similar to X-Sentry-Auth)
|
||||
public_key = extract_key_from_baffle_auth_header(request)
|
||||
return find_project(public_key, project_id) if public_key
|
||||
dsn_key = extract_key_from_baffle_auth_header(request)
|
||||
return find_dsn(dsn_key) if dsn_key
|
||||
|
||||
# Method 3: Authorization Bearer token
|
||||
public_key = extract_key_from_authorization_header(request)
|
||||
return find_project(public_key, project_id) if public_key
|
||||
dsn_key = extract_key_from_authorization_header(request)
|
||||
return find_dsn(dsn_key) if dsn_key
|
||||
|
||||
# Method 4: Basic auth (username is the public_key)
|
||||
public_key = extract_key_from_basic_auth(request)
|
||||
return find_project(public_key, project_id) if public_key
|
||||
# Method 4: Basic auth (username is the dsn_key)
|
||||
dsn_key = extract_key_from_basic_auth(request)
|
||||
return find_dsn(dsn_key) if dsn_key
|
||||
|
||||
raise AuthenticationError, "No valid authentication method found"
|
||||
end
|
||||
@@ -36,8 +36,8 @@ class DsnAuthenticationService
|
||||
auth_header = request.headers['X-Baffle-Auth'] || request.headers['X-Sentry-Auth']
|
||||
return nil unless auth_header
|
||||
|
||||
# Parse: Baffle baffle_key=public_key, baffle_version=1
|
||||
# Or: Sentry sentry_key=public_key, sentry_version=7
|
||||
# Parse: Baffle baffle_key=dsn_key, baffle_version=1
|
||||
# Or: Sentry sentry_key=dsn_key, sentry_version=7
|
||||
match = auth_header.match(/(?:baffle_key|sentry_key)=([^,\s]+)/)
|
||||
match&.[](1)
|
||||
end
|
||||
@@ -46,7 +46,7 @@ class DsnAuthenticationService
|
||||
authorization_header = request.headers['Authorization']
|
||||
return nil unless authorization_header
|
||||
|
||||
# Parse: Bearer public_key
|
||||
# Parse: Bearer dsn_key
|
||||
if authorization_header.start_with?('Bearer ')
|
||||
authorization_header[7..-1].strip
|
||||
end
|
||||
@@ -62,20 +62,16 @@ class DsnAuthenticationService
|
||||
username
|
||||
end
|
||||
|
||||
def self.find_project(public_key, project_id)
|
||||
return nil unless public_key.present? && project_id.present?
|
||||
def self.find_dsn(dsn_key)
|
||||
return nil unless dsn_key.present?
|
||||
|
||||
# Find project by public_key first
|
||||
project = Project.find_by(public_key: public_key)
|
||||
raise AuthenticationError, "Invalid public_key" unless project
|
||||
# Find DSN by key
|
||||
dsn = Dsn.authenticate(dsn_key)
|
||||
raise AuthenticationError, "Invalid DSN key" unless dsn
|
||||
|
||||
# Verify project_id matches (supports both slug and ID)
|
||||
project_matches = Project.find_by(slug: project_id) || Project.find_by(id: project_id)
|
||||
raise AuthenticationError, "Invalid project_id" unless project_matches == project
|
||||
# Ensure DSN is enabled
|
||||
raise AuthenticationError, "DSN is disabled" unless dsn.enabled?
|
||||
|
||||
# Ensure project is enabled
|
||||
raise AuthenticationError, "Project is disabled" unless project.enabled?
|
||||
|
||||
project
|
||||
dsn
|
||||
end
|
||||
end
|
||||
|
||||
343
app/views/analytics/index.html.erb
Normal file
343
app/views/analytics/index.html.erb
Normal file
@@ -0,0 +1,343 @@
|
||||
<% content_for :title, "Analytics Dashboard - Baffle Hub" %>
|
||||
|
||||
<div class="space-y-6" data-controller="dashboard" data-dashboard-period-value="<%= @time_period %>">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Analytics Dashboard</h1>
|
||||
<p class="mt-2 text-gray-600">Overview of WAF events, rules, and network activity</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Auto-refresh indicator -->
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<svg class="animate-spin h-4 w-4 mr-2 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Auto-refreshing
|
||||
</div>
|
||||
|
||||
<!-- Time Period Selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-500">Time Period:</span>
|
||||
<div class="flex bg-white rounded-md shadow-sm border border-gray-300">
|
||||
<%= link_to "1H", analytics_path(period: :hour),
|
||||
class: time_period_class(:hour),
|
||||
data: { action: "click->dashboard#periodChanged", period: "hour" } %>
|
||||
<%= link_to "24H", analytics_path(period: :day),
|
||||
class: time_period_class(:day),
|
||||
data: { action: "click->dashboard#periodChanged", period: "day" } %>
|
||||
<%= link_to "1W", analytics_path(period: :week),
|
||||
class: time_period_class(:week),
|
||||
data: { action: "click->dashboard#periodChanged", period: "week" } %>
|
||||
<%= link_to "1M", analytics_path(period: :month),
|
||||
class: time_period_class(:month),
|
||||
data: { action: "click->dashboard#periodChanged", period: "month" } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Statistics Cards -->
|
||||
<div class="space-y-6">
|
||||
<div id="dashboard-stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Total Events -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13 2.05v2.02c3.95.49 7 3.85 7 7.93 0 3.21-1.92 6-4.72 7.28l-.28.12V22l-5-5 5-5v2.4c2.21-.47 3.88-2.35 3.88-4.65 0-2.76-2.24-5-5-5-1.3 0-2.47.5-3.36 1.31L9 6.36C10.11 5.26 11.49 4.56 13 4.56V2.05c0-.45.54-.67.85-.35l8.78 8.78c.2.2.2.51 0 .71l-8.78 8.78c-.31.31-.85.1-.85-.35v-2.02c-5.05-.5-9-4.76-9-9.93 0-4.08 2.73-7.54 6.58-8.67L13 2.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Events</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_events) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="text-green-600 font-medium">Last <%= @time_period.to_s.humanize %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Rules -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Active Rules</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_rules) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="text-green-600 font-medium">Enabled</span>
|
||||
<% if @system_health[:disabled_rules] > 0 %>
|
||||
<span class="text-gray-500"> · <%= @system_health[:disabled_rules] %> disabled</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Ranges with Events -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-purple-500 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Active Network Ranges</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@network_ranges_with_events) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="text-purple-600 font-medium">of <%= number_with_delimiter(@total_network_ranges) %> total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-orange-500 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">System Health</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">Normal</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="text-green-600 font-medium">All systems operational</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts and Detailed Analytics -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Events Timeline Chart -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Events Timeline (Last 24 Hours)</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<% @chart_data[:timeline].each do |data| %>
|
||||
<div class="flex items-center">
|
||||
<div class="w-16 text-sm text-gray-500"><%= data[:time] %></div>
|
||||
<div class="flex-1 mx-4">
|
||||
<div class="bg-gray-200 rounded-full h-4">
|
||||
<div class="bg-blue-600 h-4 rounded-full"
|
||||
style="width: <%= [((data[:total].to_f / [@chart_data[:timeline].map { |d| d[:total] }.max, 1].max) * 100), 5].max %>%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 text-sm text-gray-900 text-right"><%= data[:total] %></div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Actions Breakdown -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Event Actions</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<% if @chart_data[:actions].any? %>
|
||||
<div class="space-y-4">
|
||||
<% @chart_data[:actions].each do |action| %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 rounded mr-3
|
||||
<%= case action[:action]
|
||||
when 'Allow' then 'bg-green-500'
|
||||
when 'Deny', 'Block' then 'bg-red-500'
|
||||
when 'Challenge' then 'bg-yellow-500'
|
||||
else 'bg-gray-500'
|
||||
end %>">
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900"><%= action[:action] %></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-600 mr-2"><%= number_with_delimiter(action[:count]) %></span>
|
||||
<span class="text-sm text-gray-500">(<%= action[:percentage] %>%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-center py-8">No events in the selected time period</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secondary Information Rows -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Top Countries -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Top Countries</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<% if @top_countries.any? %>
|
||||
<div class="space-y-3">
|
||||
<% @top_countries.first(5).each do |country, count| %>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-900"><%= country %></span>
|
||||
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(count) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-center py-4">No country data available</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Intelligence -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Network Intelligence</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-900">🏢 Datacenter</span>
|
||||
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(@network_intelligence[:datacenter_ranges]) %></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-900">🔒 VPN</span>
|
||||
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(@network_intelligence[:vpn_ranges]) %></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-900">🛡️ Proxy</span>
|
||||
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(@network_intelligence[:proxy_ranges]) %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div id="recent-activity" class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Recent Activity</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3">
|
||||
<% @recent_events.first(3).each do |event| %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 rounded-full mr-2
|
||||
<%= event.waf_action == 'allow' ? 'bg-green-500' : 'bg-red-500' %>"></div>
|
||||
<span class="text-gray-900 truncate max-w-[120px]"><%= event.ip_address %></span>
|
||||
</div>
|
||||
<span class="text-gray-500"><%= time_ago_in_words(event.timestamp) %> ago</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Blocked IPs -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">Top Blocked IPs</h3>
|
||||
<%= link_to "View All Events", events_path, class: "text-sm text-blue-600 hover:text-blue-800" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<% if @top_blocked_ips.any? %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<% @top_blocked_ips.each do |ip, count| %>
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-lg font-mono font-medium text-gray-900"><%= ip %></div>
|
||||
<div class="text-sm text-red-600"><%= number_with_delimiter(count) %> blocks</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-center py-8">No blocked events in the selected time period</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Quick Actions</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<%= link_to new_rule_path, class: "flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors" do %>
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</svg>
|
||||
Create Rule
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_network_range_path, class: "flex items-center justify-center px-4 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors" do %>
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</svg>
|
||||
Add Network Range
|
||||
<% end %>
|
||||
|
||||
<%= link_to events_path, class: "flex items-center justify-center px-4 py-3 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors" do %>
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
View Events
|
||||
<% end %>
|
||||
|
||||
<%= link_to rules_path, class: "flex items-center justify-center px-4 py-3 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors" do %>
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
Manage Rules
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
132
app/views/analytics/index.turbo_stream.erb
Normal file
132
app/views/analytics/index.turbo_stream.erb
Normal file
@@ -0,0 +1,132 @@
|
||||
<%= turbo_stream.replace "dashboard-stats" do %>
|
||||
<div id="dashboard-stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Total Events -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13 2.05v2.02c3.95.49 7 3.85 7 7.93 0 3.21-1.92 6-4.72 7.28l-.28.12V22l-5-5 5-5v2.4c2.21-.47 3.88-2.35 3.88-4.65 0-2.76-2.24-5-5-5-1.3 0-2.47.5-3.36 1.31L9 6.36C10.11 5.26 11.49 4.56 13 4.56V2.05c0-.45.54-.67.85-.35l8.78 8.78c.2.2.2.51 0 .71l-8.78 8.78c-.31.31-.85.1-.85-.35v-2.02c-5.05-.5-9-4.76-9-9.93 0-4.08 2.73-7.54 6.58-8.67L13 2.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Events</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_events) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="text-green-600 font-medium">Last <%= @time_period.to_s.humanize %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Rules -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Active Rules</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_rules) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="text-green-600 font-medium">Enabled</span>
|
||||
<% if @system_health[:disabled_rules] > 0 %>
|
||||
<span class="text-gray-500"> · <%= @system_health[:disabled_rules] %> disabled</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Ranges with Events -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-purple-500 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Active Network Ranges</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@network_ranges_with_events) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="text-purple-600 font-medium">of <%= number_with_delimiter(@total_network_ranges) %> total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-orange-500 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">System Health</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">Normal</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="text-green-600 font-medium">All systems operational</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream.replace "recent-activity" do %>
|
||||
<div id="recent-activity" class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Recent Activity</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3">
|
||||
<% @recent_events.first(3).each do |event| %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 rounded-full mr-2
|
||||
<%= event.waf_action == 'allow' ? 'bg-green-500' : 'bg-red-500' %>"></div>
|
||||
<span class="text-gray-900 truncate max-w-[120px]"><%= event.ip_address %></span>
|
||||
</div>
|
||||
<span class="text-gray-500"><%= time_ago_in_words(event.timestamp) %> ago</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
95
app/views/dsns/edit.html.erb
Normal file
95
app/views/dsns/edit.html.erb
Normal file
@@ -0,0 +1,95 @@
|
||||
<% content_for :title, "Edit DSN - #{@dsn.name}" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
|
||||
Edit DSN
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 flex md:mt-0 md:ml-4">
|
||||
<%= link_to "Back to DSN", @dsn, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= form_with(model: @dsn, local: true, class: "space-y-6") do |form| %>
|
||||
<% if @dsn.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
There were <%= pluralize(@dsn.errors.count, "error") %> with your submission:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul role="list" class="list-disc space-y-1 pl-5">
|
||||
<% @dsn.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div class="sm:col-span-6">
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1">
|
||||
<%= form.text_field :name, class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md", placeholder: "e.g., Production DSN, Development DSN" %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
A descriptive name to help you identify this DSN key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<%= form.check_box :enabled, class: "focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" %>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<%= form.label :enabled, class: "font-medium text-gray-700" %>
|
||||
<p class="text-gray-500">Enable this DSN for agent authentication</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DSN Key Display (Read-only) -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="text-sm font-medium text-gray-800">
|
||||
DSN Key
|
||||
</h3>
|
||||
<div class="mt-1 text-sm text-gray-600 font-mono bg-white px-3 py-2 rounded border">
|
||||
<%= @dsn.key %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">DSN keys cannot be changed after creation.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-5">
|
||||
<div class="flex justify-end">
|
||||
<%= link_to "Cancel", @dsn, class: "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
<%= form.submit "Update DSN", class: "ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
162
app/views/dsns/index.html.erb
Normal file
162
app/views/dsns/index.html.erb
Normal file
@@ -0,0 +1,162 @@
|
||||
<% content_for :title, "DSNs" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">DSN Management</h1>
|
||||
<p class="mt-2 text-gray-600">Manage DSN keys for agent authentication</p>
|
||||
</div>
|
||||
<% if policy(Dsn).create? %>
|
||||
<div class="flex space-x-3">
|
||||
<%= link_to "New DSN", new_dsn_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment DSNs -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md mb-8">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Environment DSNs</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Default DSNs configured via environment variables for agent connectivity.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200">
|
||||
<dl>
|
||||
<!-- BAFFLE_HOST DSN -->
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">External DSN (BAFFLE_HOST)</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm font-mono">
|
||||
<%= @external_dsn %>
|
||||
</code>
|
||||
<button onclick="copyToClipboard('<%= @external_dsn %>')" class="text-blue-600 hover:text-blue-800 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Host: <%= ENV['BAFFLE_HOST'] || 'localhost:3000' %></p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<% if @internal_dsn.present? %>
|
||||
<!-- BAFFLE_INTERNAL_HOST DSN -->
|
||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Internal DSN (BAFFLE_INTERNAL_HOST)</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm font-mono">
|
||||
<%= @internal_dsn %>
|
||||
</code>
|
||||
<button onclick="copyToClipboard('<%= @internal_dsn %>')" class="text-blue-600 hover:text-blue-800 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Host: <%= ENV['BAFFLE_INTERNAL_HOST'] %></p>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database DSNs -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<% if @dsns.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% @dsns.each do |dsn| %>
|
||||
<li>
|
||||
<div class="px-4 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<% if dsn.enabled? %>
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-gray-900"><%= dsn.name %></p>
|
||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= dsn.enabled? ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' %>">
|
||||
<%= dsn.enabled? ? 'Enabled' : 'Disabled' %>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 font-mono">
|
||||
Key: <%= dsn.key[0..15] + "..." %>
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Created: <%= dsn.created_at.strftime('%Y-%m-%d %H:%M') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= link_to "View", dsn, class: "text-blue-600 hover:text-blue-900 text-sm font-medium" %>
|
||||
<% if policy(dsn).edit? %>
|
||||
<%= link_to "Edit", edit_dsn_path(dsn), class: "text-indigo-600 hover:text-indigo-900 text-sm font-medium" %>
|
||||
<% end %>
|
||||
<% if policy(dsn).disable? && dsn.enabled? %>
|
||||
<%= link_to "Disable", disable_dsn_path(dsn), method: :post,
|
||||
data: { confirm: "Are you sure you want to disable this DSN?" },
|
||||
class: "text-red-600 hover:text-red-900 text-sm font-medium" %>
|
||||
<% elsif policy(dsn).enable? && !dsn.enabled? %>
|
||||
<%= link_to "Enable", enable_dsn_path(dsn), method: :post,
|
||||
class: "text-green-600 hover:text-green-900 text-sm font-medium" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No DSNs</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a new DSN.</p>
|
||||
<% if policy(Dsn).create? %>
|
||||
<div class="mt-6">
|
||||
<%= link_to "New DSN", new_dsn_path, class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// Show a brief confirmation
|
||||
const button = event.target.closest('button');
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!';
|
||||
button.classList.add('text-green-600');
|
||||
button.classList.remove('text-blue-600');
|
||||
|
||||
setTimeout(function() {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('text-green-600');
|
||||
button.classList.add('text-blue-600');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
93
app/views/dsns/new.html.erb
Normal file
93
app/views/dsns/new.html.erb
Normal file
@@ -0,0 +1,93 @@
|
||||
<% content_for :title, "New DSN" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
|
||||
New DSN
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 flex md:mt-0 md:ml-4">
|
||||
<%= link_to "Back to DSNs", dsns_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= form_with(model: @dsn, local: true, class: "space-y-6") do |form| %>
|
||||
<% if @dsn.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
There were <%= pluralize(@dsn.errors.count, "error") %> with your submission:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul role="list" class="list-disc space-y-1 pl-5">
|
||||
<% @dsn.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div class="sm:col-span-6">
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1">
|
||||
<%= form.text_field :name, class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md", placeholder: "e.g., Production DSN, Development DSN" %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
A descriptive name to help you identify this DSN key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<%= form.check_box :enabled, class: "focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" %>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<%= form.label :enabled, class: "font-medium text-gray-700" %>
|
||||
<p class="text-gray-500">Enable this DSN for agent authentication</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">
|
||||
DSN Key Information
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>A unique DSN key will be automatically generated when you create this DSN. This key will be used by your baffle-agents to authenticate with the hub.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-5">
|
||||
<div class="flex justify-end">
|
||||
<%= link_to "Cancel", dsns_path, class: "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
<%= form.submit "Create DSN", class: "ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
138
app/views/dsns/show.html.erb
Normal file
138
app/views/dsns/show.html.erb
Normal file
@@ -0,0 +1,138 @@
|
||||
<% content_for :title, "DSN - #{@dsn.name}" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
|
||||
<%= @dsn.name %>
|
||||
</h2>
|
||||
<span class="ml-3 inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium <%= @dsn.enabled? ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' %>">
|
||||
<%= @dsn.enabled? ? 'Enabled' : 'Disabled' %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex md:mt-0 md:ml-4 space-x-3">
|
||||
<%= link_to "Back to DSNs", dsns_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
||||
<% if policy(@dsn).edit? %>
|
||||
<%= link_to "Edit", edit_dsn_path(@dsn), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">DSN Information</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
DSN key details and usage information.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200">
|
||||
<dl>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @dsn.name %></dd>
|
||||
</div>
|
||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">DSN Key</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm font-mono"><%= @dsn.key %></code>
|
||||
<button onclick="copyToClipboard('<%= @dsn.key %>')" class="text-blue-600 hover:text-blue-800 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= @dsn.enabled? ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' %>">
|
||||
<%= @dsn.enabled? ? 'Enabled' : 'Disabled' %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= @dsn.created_at.strftime('%B %d, %Y at %I:%M %p') %>
|
||||
</dd>
|
||||
</div>
|
||||
<% if @dsn.updated_at != @dsn.created_at %>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= @dsn.updated_at.strftime('%B %d, %Y at %I:%M %p') %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<% if policy(@dsn).disable? || policy(@dsn).enable? %>
|
||||
<div class="mt-6 bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Actions</h3>
|
||||
<div class="mt-5 space-x-3">
|
||||
<% if @dsn.enabled? && policy(@dsn).disable? %>
|
||||
<%= link_to "Disable DSN", disable_dsn_path(@dsn), method: :post,
|
||||
data: { confirm: "Are you sure you want to disable this DSN? Agents will no longer be able to authenticate with this key." },
|
||||
class: "inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
|
||||
<% elsif !@dsn.enabled? && policy(@dsn).enable? %>
|
||||
<%= link_to "Enable DSN", enable_dsn_path(@dsn), method: :post,
|
||||
class: "inline-flex items-center px-4 py-2 border border-green-300 rounded-md shadow-sm text-sm font-medium text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Usage Instructions -->
|
||||
<div class="mt-6 bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Usage Instructions</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
How to use this DSN key with your baffle-agents.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<div class="prose max-w-none">
|
||||
<h4>HTTP Header Authentication</h4>
|
||||
<p>Include the DSN key in the <code>Authorization</code> header:</p>
|
||||
<pre class="bg-gray-100 p-3 rounded text-sm"><code>Authorization: Bearer <%= @dsn.key %></code></pre>
|
||||
|
||||
<h4 class="mt-4">Query Parameter Authentication</h4>
|
||||
<p>Include the DSN key as a query parameter:</p>
|
||||
<pre class="bg-gray-100 p-3 rounded text-sm"><code>/api/events?baffle_key=<%= @dsn.key %></code></pre>
|
||||
|
||||
<h4 class="mt-4">X-Baffle-Auth Header</h4>
|
||||
<p>Use the custom Baffle authentication header:</p>
|
||||
<pre class="bg-gray-100 p-3 rounded text-sm"><code>X-Baffle-Auth: Baffle baffle_key=<%= @dsn.key %>, baffle_version=1</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// Show a brief confirmation
|
||||
const button = event.target.closest('button');
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!';
|
||||
button.classList.add('text-green-600');
|
||||
button.classList.remove('text-blue-600');
|
||||
|
||||
setTimeout(function() {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('text-green-600');
|
||||
button.classList.add('text-blue-600');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1,112 +1,157 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><%= @project.name %> - Events</h1>
|
||||
<% content_for :title, "Events - Baffle Hub" %>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<%= link_to "← Back to Project", @project, class: "btn btn-secondary" %>
|
||||
<%= link_to "Analytics", analytics_project_path(@project), class: "btn btn-info" %>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Events</h1>
|
||||
<p class="mt-2 text-gray-600">WAF event log and analysis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Filters</h5>
|
||||
<!-- Filters -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Filters</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<%= form_with url: events_path, method: :get, local: true, class: "space-y-4" do |form| %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<%= form.label :ip, "IP Address", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :ip, value: params[:ip],
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
placeholder: "Filter by IP" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :waf_action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :waf_action,
|
||||
options_for_select([['All', ''], ['Allow', 'allow'], ['Block', 'block'], ['Challenge', 'challenge']], params[:waf_action]),
|
||||
{ }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :country, "Country", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :country, value: params[:country],
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
placeholder: "Country code (e.g. US)" %>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2">
|
||||
<%= form.submit "Apply Filters",
|
||||
class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
<%= link_to "Clear", events_path,
|
||||
class: "inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<%= form_with url: project_events_path(@project), method: :get, local: true, class: "row g-3" do |form| %>
|
||||
<div class="col-md-3">
|
||||
<%= form.label :ip, "IP Address", class: "form-label" %>
|
||||
<%= form.text_field :ip, value: params[:ip], class: "form-control", placeholder: "Filter by IP" %>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<%= form.label :waf_action, "Action", class: "form-label" %>
|
||||
<%= form.select :waf_action,
|
||||
options_for_select([['All', ''], ['Allow', 'allow'], ['Block', 'block'], ['Challenge', 'challenge']], params[:waf_action]),
|
||||
{}, { class: "form-select" } %>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<%= form.label :country, "Country", class: "form-label" %>
|
||||
<%= form.text_field :country, value: params[:country], class: "form-control", placeholder: "Country code (e.g. US)" %>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<%= form.submit "Apply Filters", class: "btn btn-primary me-2" %>
|
||||
<%= link_to "Clear", project_events_path(@project), class: "btn btn-outline-secondary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Table -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5>Events (<%= @events.count %>)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<% if @events.any? %>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<!-- Events Table -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">Events (<%= number_with_delimiter(@events.count) %>)</h3>
|
||||
<div class="flex items-center space-x-4">
|
||||
<%= link_to "📊 Analytics Dashboard", analytics_path,
|
||||
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
|
||||
<% if @pagy.pages > 1 %>
|
||||
<span class="text-sm text-gray-500">
|
||||
Page <%= @pagy.page %> of <%= @pagy.pages %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Top Pagination -->
|
||||
<% if @pagy.pages > 1 %>
|
||||
<div class="mt-4">
|
||||
<%= pagy_nav_tailwind(@pagy, pagy_id: 'events_top') %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<% if @events.any? %>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>IP Address</th>
|
||||
<th>Action</th>
|
||||
<th>Path</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
<th>Country</th>
|
||||
<th>User Agent</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP Address</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Path</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @events.each do |event| %>
|
||||
<tr>
|
||||
<td><%= event.timestamp.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
||||
<td><code><%= event.ip_address %></code></td>
|
||||
<td>
|
||||
<span class="badge bg-<%= event.blocked? ? 'danger' : event.allowed? ? 'success' : 'warning' %>">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<%= event.timestamp.strftime("%Y-%m-%d %H:%M:%S") %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
<%= event.ip_address %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
<%= case event.waf_action
|
||||
when 'allow' then 'bg-green-100 text-green-800'
|
||||
when 'deny', 'block' then 'bg-red-100 text-red-800'
|
||||
when 'challenge' then 'bg-yellow-100 text-yellow-800'
|
||||
else 'bg-gray-100 text-gray-800'
|
||||
end %>">
|
||||
<%= event.waf_action %>
|
||||
</span>
|
||||
</td>
|
||||
<td><code><%= event.request_path %></code></td>
|
||||
<td><%= event.request_method %></td>
|
||||
<td><%= event.response_status %></td>
|
||||
<td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 max-w-xs truncate" title="<%= event.request_path %>">
|
||||
<%= event.request_path || '-' %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<%= event.request_method ? event.request_method.upcase : '-' %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<%= event.response_status || '-' %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<% if event.country_code.present? %>
|
||||
<span class="badge bg-light text-dark"><%= event.country_code %></span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<%= event.country_code %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-muted">-</span>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width: 200px;" title="<%= event.user_agent %>">
|
||||
<%= event.user_agent&.truncate(30) || '-' %>
|
||||
<td class="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title="<%= event.user_agent %>">
|
||||
<%= event.user_agent&.truncate(50) || '-' %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<% if @pagy.pages > 1 %>
|
||||
<div class="d-flex justify-content-center mt-4">
|
||||
<%== pagy_nav(@pagy) %>
|
||||
</div>
|
||||
<div class="text-center text-muted mt-2">
|
||||
Showing <%= @pagy.from %> to <%= @pagy.to %> of <%= @pagy.count %> events
|
||||
<!-- Bottom Pagination -->
|
||||
<% if @pagy.pages > 1 %>
|
||||
<%= pagy_nav_tailwind(@pagy, pagy_id: 'events_bottom') %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No events</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %>
|
||||
No events found matching your filters.
|
||||
<% else %>
|
||||
No events have been received yet.
|
||||
<% end %>
|
||||
</p>
|
||||
<% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %>
|
||||
<div class="mt-6">
|
||||
<%= link_to "Clear Filters", events_path,
|
||||
class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-blue-600 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-center py-5">
|
||||
<p class="text-muted mb-3">
|
||||
<% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %>
|
||||
No events found matching your filters.
|
||||
<% else %>
|
||||
No events have been received yet.
|
||||
<% end %>
|
||||
</p>
|
||||
<% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %>
|
||||
<%= link_to "Clear Filters", project_events_path(@project), class: "btn btn-outline-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html class="bg-gray-50">
|
||||
<head>
|
||||
<title><%= content_for(:title) || "Baffle Hub - WAF Analytics" %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -18,103 +18,196 @@
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.png">
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
<style>
|
||||
.badge { font-size: 0.8em; }
|
||||
/* Custom styles for code blocks and badges */
|
||||
code {
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
@apply bg-gray-100 px-2 py-1 rounded text-sm font-mono;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
|
||||
/* Flash message transitions */
|
||||
.flash-message {
|
||||
animation: slideInDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<%= link_to "Baffle Hub", root_path, class: "navbar-brand" %>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<%= link_to "Projects", projects_path, class: "nav-link" %>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<%= link_to "Rules", rules_path, class: "nav-link" %>
|
||||
</li>
|
||||
<% if user_signed_in? && current_user_admin? %>
|
||||
<li class="nav-item">
|
||||
<%= link_to "Users", users_path, class: "nav-link" %>
|
||||
</li>
|
||||
<body class="min-h-screen bg-gray-50">
|
||||
<!-- Navigation Header -->
|
||||
<header class="bg-gray-900 shadow-sm border-b border-gray-800">
|
||||
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Logo and Main Navigation -->
|
||||
<div class="flex items-center">
|
||||
<!-- Logo -->
|
||||
<%= link_to root_path, class: "flex-shrink-0 flex items-center" do %>
|
||||
<svg class="h-8 w-8 text-blue-500 mr-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
<span class="text-white text-lg font-bold">Baffle Hub</span>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:block ml-10">
|
||||
<div class="flex items-baseline space-x-4">
|
||||
<%= link_to "🔴 Events", events_path,
|
||||
class: nav_link_class(events_path) %>
|
||||
<%= link_to "⚙️ Rules", rules_path,
|
||||
class: nav_link_class(rules_path) %>
|
||||
<%= link_to "🌐 Network Ranges", network_ranges_path,
|
||||
class: nav_link_class(network_ranges_path) %>
|
||||
|
||||
<% if user_signed_in? && current_user_admin? %>
|
||||
<%= link_to "🔗 DSNs", dsns_path,
|
||||
class: nav_link_class(dsns_path) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<% if user_signed_in? %>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<!-- User dropdown -->
|
||||
<div class="relative" data-controller="dropdown">
|
||||
<button type="button"
|
||||
data-action="click->dropdown#toggle click@window->dropdown#hide"
|
||||
class="flex items-center text-white hover:bg-gray-800 px-3 py-2 rounded-md text-sm font-medium">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
<%= current_user.email_address %>
|
||||
<span class="badge bg-secondary ms-1"><%= current_user.role %></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><%= link_to "Account Settings", edit_password_path, class: "dropdown-item" %></li>
|
||||
<% if current_user_admin? %>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><%= link_to "Manage Users", users_path, class: "dropdown-item" %></li>
|
||||
<% end %>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><%= link_to "Sign Out", session_path, data: { turbo_method: :delete }, class: "dropdown-item" %></li>
|
||||
</ul>
|
||||
</li>
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-600 text-white">
|
||||
<%= current_user.role %>
|
||||
</span>
|
||||
<svg class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div data-dropdown-target="menu"
|
||||
class="hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50">
|
||||
<div class="py-1">
|
||||
<%= link_to edit_password_path,
|
||||
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
|
||||
⚙️ Account Settings
|
||||
<% end %>
|
||||
|
||||
<% if current_user_admin? %>
|
||||
<div class="border-t border-gray-100"></div>
|
||||
<%= link_to users_path,
|
||||
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
|
||||
👥 Manage Users
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-gray-100"></div>
|
||||
<%= link_to session_path,
|
||||
data: { turbo_method: :delete },
|
||||
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
|
||||
🚪 Sign Out
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<% if User.none? %>
|
||||
<li class="nav-item">
|
||||
<%= link_to "Create Admin Account", new_registration_path, class: "nav-link btn btn-success text-white" %>
|
||||
</li>
|
||||
<%= link_to "👤 Create Admin Account", new_registration_path,
|
||||
class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors" %>
|
||||
<% else %>
|
||||
<li class="nav-item">
|
||||
<%= link_to "Sign In", new_session_path, class: "nav-link" %>
|
||||
</li>
|
||||
<%= link_to "🔐 Sign In", new_session_path,
|
||||
class: "bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="md:hidden">
|
||||
<button type="button"
|
||||
data-controller="mobile-menu"
|
||||
data-action="click->mobile-menu#toggle"
|
||||
class="text-white hover:bg-gray-800 p-2 rounded-md">
|
||||
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path data-mobile-menu-target="open" fill-rule="evenodd" d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z"/>
|
||||
<path data-mobile-menu-target="close" class="hidden" fill-rule="evenodd" d="M18.278 16.864a1 1 0 01-1.414 1.414l-4.829-4.828-4.828 4.828a1 1 0 01-1.414-1.414l4.828-4.829-4.828-4.828a1 1 0 011.414-1.414l4.829 4.828 4.828-4.828a1 1 0 111.414 1.414l-4.828 4.829 4.828 4.828z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div data-mobile-menu-target="menu" class="hidden md:hidden border-t border-gray-800">
|
||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||
<%= link_to "🔴 Events", events_path,
|
||||
class: mobile_nav_link_class(events_path) %>
|
||||
<%= link_to "⚙️ Rules", rules_path,
|
||||
class: mobile_nav_link_class(rules_path) %>
|
||||
<%= link_to "🌐 Network Ranges", network_ranges_path,
|
||||
class: mobile_nav_link_class(network_ranges_path) %>
|
||||
|
||||
<% if user_signed_in? && current_user_admin? %>
|
||||
<%= link_to "🔗 DSNs", dsns_path,
|
||||
class: mobile_nav_link_class(dsns_path) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<% if notice || alert %>
|
||||
<div class="fixed top-20 left-0 right-0 z-50 px-4 py-2">
|
||||
<% if notice %>
|
||||
<div class="flash-message bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-md shadow-sm max-w-4xl mx-auto">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-green-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<%= notice %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if alert %>
|
||||
<div class="flash-message bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-md shadow-sm max-w-4xl mx-auto">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-red-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
<%= alert %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 text-gray-400 mt-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="text-center">
|
||||
<p class="text-sm">© <%= Time.current.year %> Baffle Hub - WAF Analytics Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<% if notice %>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<%= notice %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if alert %>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<%= alert %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
91
app/views/layouts/authentication.html.erb
Normal file
91
app/views/layouts/authentication.html.erb
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="bg-gray-50">
|
||||
<head>
|
||||
<title><%= content_for(:title) || "Baffle Hub - WAF Analytics" %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="application-name" content="Baffle Hub">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= yield :head %>
|
||||
|
||||
<link rel="icon" href="/icon.png" type="image/png">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.png">
|
||||
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
<style>
|
||||
/* Custom styles for code blocks and badges */
|
||||
code {
|
||||
@apply bg-gray-100 px-2 py-1 rounded text-sm font-mono;
|
||||
}
|
||||
|
||||
/* Flash message transitions */
|
||||
.flash-message {
|
||||
animation: slideInDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-gray-50">
|
||||
<!-- Minimal Navigation for Auth Pages -->
|
||||
<%= render 'shared/auth_navigation' %>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<% if notice || alert %>
|
||||
<div class="fixed top-20 left-0 right-0 z-50 px-4 py-2">
|
||||
<% if notice %>
|
||||
<div class="flash-message bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-md shadow-sm max-w-4xl mx-auto">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-green-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<%= notice %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if alert %>
|
||||
<div class="flash-message bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-md shadow-sm max-w-4xl mx-auto">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-red-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
<%= alert %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-md mx-auto px-4 py-8">
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white text-gray-500 mt-12 border-t border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="text-center">
|
||||
<p class="text-sm">© <%= Time.current.year %> Baffle Hub - WAF Analytics Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +1,21 @@
|
||||
<% content_for :title, "Network Ranges - #{@project.name}" %>
|
||||
<% content_for :title, "Network Ranges" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Network Ranges</h1>
|
||||
<p class="mt-2 text-gray-600">Browse and manage network ranges with intelligence data</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<%= link_to "IP Lookup", lookup_network_ranges_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
||||
<%= link_to "Add Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Network Ranges</h1>
|
||||
<p class="mt-2 text-gray-600">Browse and manage network ranges with intelligence data</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<%= link_to "IP Lookup", lookup_network_ranges_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
||||
<%= link_to "Add Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters -->
|
||||
<% if params[:asn].present? || params[:country].present? || params[:company].present? || params[:datacenter].present? || params[:vpn].present? || params[:proxy].present? || params[:source].present? || params[:search].present? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-blue-900">Active Filters</h3>
|
||||
@@ -225,8 +223,23 @@
|
||||
<!-- Network Ranges Table -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Network Ranges</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Showing <%= @network_ranges.count %> of <%= number_with_delimiter(@total_ranges) %> ranges</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Network Ranges</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Showing <%= @network_ranges.count %> of <%= number_with_delimiter(@total_ranges) %> ranges</p>
|
||||
</div>
|
||||
<% if @pagy.present? && @pagy.pages > 1 %>
|
||||
<span class="text-sm text-gray-500">
|
||||
Page <%= @pagy.page %> of <%= @pagy.pages %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<!-- Top Pagination -->
|
||||
<% if @pagy.present? && @pagy.pages > 1 %>
|
||||
<div class="mt-4">
|
||||
<%= pagy_nav_tailwind(@pagy, pagy_id: 'network_ranges_top') %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-200">
|
||||
@@ -304,10 +317,10 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<% if @pagy.present? %>
|
||||
<div class="mt-6 flex justify-center">
|
||||
<%= pagy_nav(@pagy) %>
|
||||
<!-- Bottom Pagination -->
|
||||
<% if @pagy.present? && @pagy.pages > 1 %>
|
||||
<div class="mt-6">
|
||||
<%= pagy_nav_tailwind(@pagy, pagy_id: 'network_ranges_bottom') %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -159,22 +159,22 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Traffic Statistics</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900"><%= number_with_delimiter(@traffic_stats[:total_requests]) %></div>
|
||||
<div class="text-sm text-gray-500">Total Requests</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="text-center min-w-0">
|
||||
<div class="text-2xl font-bold text-gray-900 break-words"><%= number_with_delimiter(@traffic_stats[:total_requests]) %></div>
|
||||
<div class="text-sm text-gray-500 whitespace-normal">Total Requests</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900"><%= number_with_delimiter(@traffic_stats[:unique_ips]) %></div>
|
||||
<div class="text-sm text-gray-500">Unique IPs</div>
|
||||
<div class="text-center min-w-0">
|
||||
<div class="text-2xl font-bold text-gray-900 break-words"><%= number_with_delimiter(@traffic_stats[:unique_ips]) %></div>
|
||||
<div class="text-sm text-gray-500 whitespace-normal">Unique IPs</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600"><%= number_with_delimiter(@traffic_stats[:allowed_requests]) %></div>
|
||||
<div class="text-sm text-gray-500">Allowed</div>
|
||||
<div class="text-center min-w-0">
|
||||
<div class="text-2xl font-bold text-green-600 break-words"><%= number_with_delimiter(@traffic_stats[:allowed_requests]) %></div>
|
||||
<div class="text-sm text-gray-500 whitespace-normal">Allowed</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600"><%= number_with_delimiter(@traffic_stats[:blocked_requests]) %></div>
|
||||
<div class="text-sm text-gray-500">Blocked</div>
|
||||
<div class="text-center min-w-0">
|
||||
<div class="text-2xl font-bold text-red-600 break-words"><%= number_with_delimiter(@traffic_stats[:blocked_requests]) %></div>
|
||||
<div class="text-sm text-gray-500 whitespace-normal">Blocked</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,11 +196,197 @@
|
||||
<% end %>
|
||||
|
||||
<!-- Associated Rules -->
|
||||
<% if @associated_rules.any? %>
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">Associated Rules (<%= @associated_rules.count %>)</h3>
|
||||
<button type="button" onclick="toggleQuickCreateRule()" class="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Quick Create Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Create Rule Form -->
|
||||
<div id="quick_create_rule" class="hidden border-b border-gray-200">
|
||||
<div class="px-6 py-4 bg-blue-50">
|
||||
<%= form_with(model: Rule.new, url: rules_path, local: true,
|
||||
class: "space-y-4",
|
||||
data: { turbo: false }) do |form| %>
|
||||
|
||||
<!-- Hidden network range ID -->
|
||||
<%= form.hidden_field :network_range_id, value: @network_range.id %>
|
||||
|
||||
<!-- Network Context Display -->
|
||||
<div class="mb-4 p-3 bg-blue-100 border border-blue-200 rounded-md">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="text-sm text-blue-800">
|
||||
<strong>Creating rule for:</strong> <%= @network_range.cidr %>
|
||||
<% if @network_range.company.present? %>
|
||||
- <%= @network_range.company %>
|
||||
<% end %>
|
||||
<% if @network_range.asn_org.present? %>
|
||||
(ASN: <%= @network_range.asn_org %>)
|
||||
<% end %>
|
||||
<% if @network_range.is_datacenter? || @network_range.is_vpn? || @network_range.is_proxy? %>
|
||||
<span class="ml-2">
|
||||
<% if @network_range.is_datacenter? %><span class="bg-orange-200 px-2 py-0.5 rounded text-xs">Datacenter</span><% end %>
|
||||
<% if @network_range.is_vpn? %><span class="bg-purple-200 px-2 py-0.5 rounded text-xs">VPN</span><% end %>
|
||||
<% if @network_range.is_proxy? %><span class="bg-red-200 px-2 py-0.5 rounded text-xs">Proxy</span><% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Rule Type -->
|
||||
<div>
|
||||
<%= form.label :rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :rule_type,
|
||||
options_for_select([
|
||||
['Network - IP/CIDR based blocking', 'network'],
|
||||
['Rate Limit - Request rate limiting', 'rate_limit'],
|
||||
['Path Pattern - URL path filtering', 'path_pattern'],
|
||||
['Header Pattern - HTTP header filtering', 'header_pattern'],
|
||||
['Query Pattern - Query parameter filtering', 'query_pattern'],
|
||||
['Body Signature - Request body filtering', 'body_signature']
|
||||
], 'network'),
|
||||
{ },
|
||||
{
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
id: "quick_rule_type_select",
|
||||
onchange: "toggleRuleTypeFields()"
|
||||
} %>
|
||||
<p class="mt-1 text-xs text-gray-500">Select the type of rule to create</p>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :action,
|
||||
options_for_select([
|
||||
['Deny - Block requests', 'deny'],
|
||||
['Allow - Whitelist requests', 'allow'],
|
||||
['Rate Limit - Throttle requests', 'rate_limit'],
|
||||
['Redirect - Redirect to URL', 'redirect'],
|
||||
['Challenge - Present CAPTCHA', 'challenge'],
|
||||
['Monitor - Log but allow', 'monitor']
|
||||
], 'deny'),
|
||||
{ },
|
||||
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||
<p class="mt-1 text-xs text-gray-500">Action to take when rule matches</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expires At -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= form.label :expires_at, "Expires At (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.datetime_local_field :expires_at,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Leave blank for permanent rule</p>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 flex items-center pt-6">
|
||||
<svg class="w-4 h-4 text-blue-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Priority will be calculated automatically based on rule type
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pattern-based Rule Fields -->
|
||||
<div id="pattern_fields" class="hidden space-y-4">
|
||||
<div>
|
||||
<%= form.label :conditions, "Pattern/Conditions", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :conditions, rows: 3,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
placeholder: "Enter pattern or JSON conditions...",
|
||||
id: "quick_conditions_field" %>
|
||||
<p class="mt-1 text-xs text-gray-500" id="pattern_help_text">Pattern will be used for matching</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Fields -->
|
||||
<div id="rate_limit_fields" class="hidden space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= label_tag :rate_limit, "Request Limit", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= number_field_tag :rate_limit, 10,
|
||||
min: 1,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Maximum requests per time window</p>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag :rate_window, "Time Window (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= number_field_tag :rate_window, 60,
|
||||
min: 1,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Time window in seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redirect Fields -->
|
||||
<div id="redirect_fields" class="hidden space-y-4">
|
||||
<div>
|
||||
<%= label_tag :redirect_url, "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag :redirect_url,
|
||||
"https://example.com/blocked",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">URL to redirect to when rule matches</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div>
|
||||
<%= form.label :metadata, "Reason/Description", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :metadata,
|
||||
placeholder: "e.g., Block malicious traffic from this range",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Human-readable description of why this rule exists</p>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :source,
|
||||
options_for_select([
|
||||
['Manual', 'manual'],
|
||||
['Auto-detected', 'auto'],
|
||||
['Hub Sync', 'hub'],
|
||||
['Imported', 'imported']
|
||||
], 'manual'),
|
||||
{ },
|
||||
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
<%= form.check_box :enabled, checked: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :enabled, "Enable immediately", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t border-blue-200">
|
||||
<button type="button" onclick="toggleQuickCreateRule()" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<%= form.submit "Create Rule", class: "px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rules List -->
|
||||
<% if @associated_rules.any? %>
|
||||
<div class="divide-y divide-gray-200">
|
||||
<% @associated_rules.each do |rule| %>
|
||||
<div class="px-6 py-4">
|
||||
@@ -214,6 +400,9 @@
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Priority: <%= rule.priority %>
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<%= rule.rule_type.humanize %>
|
||||
</span>
|
||||
<% if rule.source.include?('surgical') %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
Surgical
|
||||
@@ -242,8 +431,16 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="px-6 py-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No rules yet</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a rule for this network range.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Network Relationships -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
@@ -344,4 +541,87 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleQuickCreateRule() {
|
||||
const formDiv = document.getElementById('quick_create_rule');
|
||||
formDiv.classList.toggle('hidden');
|
||||
|
||||
// Reset form when hiding
|
||||
if (formDiv.classList.contains('hidden')) {
|
||||
resetQuickCreateForm();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRuleTypeFields() {
|
||||
const ruleType = document.getElementById('quick_rule_type_select').value;
|
||||
const action = document.querySelector('select[name="rule[action]"]').value;
|
||||
|
||||
// Hide all optional fields
|
||||
document.getElementById('pattern_fields').classList.add('hidden');
|
||||
document.getElementById('rate_limit_fields').classList.add('hidden');
|
||||
document.getElementById('redirect_fields').classList.add('hidden');
|
||||
|
||||
// Show relevant fields based on rule type
|
||||
if (['path_pattern', 'header_pattern', 'query_pattern', 'body_signature'].includes(ruleType)) {
|
||||
document.getElementById('pattern_fields').classList.remove('hidden');
|
||||
updatePatternHelpText(ruleType);
|
||||
} else if (ruleType === 'rate_limit') {
|
||||
document.getElementById('rate_limit_fields').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Show redirect fields if action is redirect
|
||||
if (action === 'redirect') {
|
||||
document.getElementById('redirect_fields').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePatternHelpText(ruleType) {
|
||||
const helpText = document.getElementById('pattern_help_text');
|
||||
const conditionsField = document.getElementById('quick_conditions_field');
|
||||
|
||||
switch(ruleType) {
|
||||
case 'path_pattern':
|
||||
helpText.textContent = 'Regex pattern to match URL paths (e.g.,\\.env$|wp-admin|phpmyadmin)';
|
||||
conditionsField.placeholder = 'Example: \\.env$|\\.git|config\\.php|wp-admin';
|
||||
break;
|
||||
case 'header_pattern':
|
||||
helpText.textContent = 'JSON with header_name and pattern (e.g., {"header_name": "User-Agent", "pattern": "bot.*"})';
|
||||
conditionsField.placeholder = 'Example: {"header_name": "User-Agent", "pattern": ".*[Bb]ot.*"}';
|
||||
break;
|
||||
case 'query_pattern':
|
||||
helpText.textContent = 'Regex pattern to match query parameters (e.g., union.*select|<script)';
|
||||
conditionsField.placeholder = 'Example: (?:union|select|insert|update|delete).*\\s+(?:union|select)';
|
||||
break;
|
||||
case 'body_signature':
|
||||
helpText.textContent = 'Regex pattern to match request body content (e.g., OR 1=1|<script)';
|
||||
conditionsField.placeholder = 'Example: (?:OR\\s+1\\s*=\\s*1|AND\\s+1\\s*=\\s*1|UNION\\s+SELECT)';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function resetQuickCreateForm() {
|
||||
const form = document.querySelector('#quick_create_rule form');
|
||||
if (form) {
|
||||
form.reset();
|
||||
// Reset rule type to default
|
||||
document.getElementById('quick_rule_type_select').value = 'network';
|
||||
toggleRuleTypeFields();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the form visibility state
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set up action change listener to show/hide redirect fields
|
||||
const actionSelect = document.querySelector('select[name="rule[action]"]');
|
||||
if (actionSelect) {
|
||||
actionSelect.addEventListener('change', function() {
|
||||
toggleRuleTypeFields();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize field visibility
|
||||
toggleRuleTypeFields();
|
||||
});
|
||||
</script>
|
||||
@@ -1,200 +0,0 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><%= @project.name %> - Analytics</h1>
|
||||
<div>
|
||||
<%= link_to "← Back to Project", project_path(@project), class: "btn btn-secondary" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Range Selector -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Time Range</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<%= form_with url: analytics_project_path(@project), method: :get, local: true do |form| %>
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-6">
|
||||
<%= form.label :time_range, "Time Range", class: "form-label" %>
|
||||
<%= form.select :time_range,
|
||||
options_for_select([
|
||||
["Last Hour", 1],
|
||||
["Last 6 Hours", 6],
|
||||
["Last 24 Hours", 24],
|
||||
["Last 7 Days", 168],
|
||||
["Last 30 Days", 720]
|
||||
], @time_range),
|
||||
{}, class: "form-select" %>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<%= form.submit "Update", class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-primary"><%= number_with_delimiter(@total_events) %></h3>
|
||||
<p class="card-text">Total Events</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-success"><%= number_with_delimiter(@allowed_events) %></h3>
|
||||
<p class="card-text">Allowed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-danger"><%= number_with_delimiter(@blocked_events) %></h3>
|
||||
<p class="card-text">Blocked</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Top Blocked IPs -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Top Blocked IPs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<% if @top_blocked_ips.any? %>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Blocked Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @top_blocked_ips.each do |stat| %>
|
||||
<tr>
|
||||
<td><code><%= stat.ip_address %></code></td>
|
||||
<td><%= number_with_delimiter(stat.count) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-muted">No blocked events in this time range.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Country Distribution -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Top Countries</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<% if @country_stats.any? %>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th>Events</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @country_stats.each do |stat| %>
|
||||
<tr>
|
||||
<td><%= stat.country_code || 'Unknown' %></td>
|
||||
<td><%= number_with_delimiter(stat.count) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-muted">No country data available.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Distribution -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Action Distribution</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<% if @action_stats.any? %>
|
||||
<div class="row">
|
||||
<% @action_stats.each do |stat| %>
|
||||
<div class="col-md-3 text-center mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4><%= stat.action.upcase %></h4>
|
||||
<p class="card-text">
|
||||
<span class="badge bg-<%=
|
||||
case stat.action
|
||||
when 'allow', 'pass' then 'success'
|
||||
when 'block', 'deny' then 'danger'
|
||||
when 'challenge' then 'warning'
|
||||
when 'rate_limit' then 'info'
|
||||
else 'secondary'
|
||||
end %>">
|
||||
<%= number_with_delimiter(stat.count) %>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-muted">No action data available.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @total_events > 0 %>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Block Rate</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="progress" style="height: 30px;">
|
||||
<% blocked_percentage = (@blocked_events.to_f / @total_events * 100).round(1) %>
|
||||
<% allowed_percentage = (@allowed_events.to_f / @total_events * 100).round(1) %>
|
||||
|
||||
<div class="progress-bar bg-success" style="width: <%= allowed_percentage %>%">
|
||||
<%= allowed_percentage %>% Allowed
|
||||
</div>
|
||||
<div class="progress-bar bg-danger" style="width: <%= blocked_percentage %>%">
|
||||
<%= blocked_percentage %>% Blocked
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4">
|
||||
<%= link_to "View Events", events_project_path(@project), class: "btn btn-primary" %>
|
||||
<%= link_to "Export Data", "#", class: "btn btn-secondary", onclick: "alert('Export feature coming soon!')" %>
|
||||
</div>
|
||||
@@ -1,49 +0,0 @@
|
||||
<h1>Projects</h1>
|
||||
|
||||
<%= link_to "New Project", new_project_path, class: "btn btn-primary mb-3" %>
|
||||
|
||||
<div class="row">
|
||||
<% @projects.each do |project| %>
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><%= project.name %></h5>
|
||||
<span class="badge <%= project.enabled? ? 'bg-success' : 'bg-secondary' %>">
|
||||
<%= project.enabled? ? 'Active' : 'Disabled' %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">
|
||||
<strong>Status:</strong>
|
||||
<span class="badge bg-<%= project.waf_status == 'active' ? 'success' : project.waf_status == 'idle' ? 'warning' : 'danger' %>">
|
||||
<%= project.waf_status %>
|
||||
</span>
|
||||
</p>
|
||||
<p class="card-text">
|
||||
<strong>Events (24h):</strong> <%= project.event_count(24.hours.ago) %>
|
||||
</p>
|
||||
<p class="card-text">
|
||||
<strong>Blocked (24h):</strong> <%= project.blocked_count(24.hours.ago) %>
|
||||
</p>
|
||||
<small class="text-muted">
|
||||
<strong>DSN:</strong><br>
|
||||
<code><%= project.dsn %></code>
|
||||
</small>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<%= link_to "View", project_path(project), class: "btn btn-primary btn-sm" %>
|
||||
<%= link_to "Events", project_events_path(project), class: "btn btn-secondary btn-sm" %>
|
||||
<%= link_to "Analytics", analytics_project_path(project), class: "btn btn-info btn-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @projects.empty? %>
|
||||
<div class="text-center my-5">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create your first project to start monitoring WAF events.</p>
|
||||
<%= link_to "Create Project", new_project_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,32 +0,0 @@
|
||||
<h1>New Project</h1>
|
||||
|
||||
<%= form_with(model: @project, local: true) do |form| %>
|
||||
<% if @project.errors.any? %>
|
||||
<div class="alert alert-danger">
|
||||
<h4><%= pluralize(@project.errors.count, "error") %> prohibited this project from being saved:</h4>
|
||||
<ul>
|
||||
<% @project.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= form.label :name, class: "form-label" %>
|
||||
<%= form.text_field :name, class: "form-control" %>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= form.label :enabled, class: "form-label" %>
|
||||
<div class="form-check">
|
||||
<%= form.check_box :enabled, class: "form-check-input" %>
|
||||
<%= form.label :enabled, "Enable this project", class: "form-check-label" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= form.submit "Create Project", class: "btn btn-primary" %>
|
||||
<%= link_to "Cancel", projects_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,118 +0,0 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><%= @project.name %></h1>
|
||||
<div>
|
||||
<%= link_to "Edit", edit_project_path(@project), class: "btn btn-secondary" %>
|
||||
<%= link_to "Events", project_events_path(@project), class: "btn btn-primary" %>
|
||||
<%= link_to "Analytics", analytics_project_path(@project), class: "btn btn-info" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Project Status</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Status:</strong>
|
||||
<span class="badge bg-<%= @waf_status == 'active' ? 'success' : @waf_status == 'idle' ? 'warning' : 'danger' %>">
|
||||
<%= @waf_status %>
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Enabled:</strong>
|
||||
<span class="badge bg-<%= @project.enabled? ? 'success' : 'secondary' %>">
|
||||
<%= @project.enabled? ? 'Yes' : 'No' %>
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Events (24h):</strong> <%= @event_count %></p>
|
||||
<p><strong>Blocked (24h):</strong> <%= @blocked_count %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>DSN Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>DSN:</strong></p>
|
||||
<code><%= @project.dsn %></code>
|
||||
<button class="btn btn-sm btn-outline-primary ms-2" onclick="copyDSN()">Copy</button>
|
||||
|
||||
<% if @project.internal_dsn.present? %>
|
||||
<hr>
|
||||
<p><strong>Internal DSN:</strong></p>
|
||||
<code><%= @project.internal_dsn %></code>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Recent Events</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<% if @recent_events.any? %>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>IP</th>
|
||||
<th>Action</th>
|
||||
<th>Path</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @recent_events.limit(5).each do |event| %>
|
||||
<tr>
|
||||
<td><%= event.timestamp.strftime("%H:%M:%S") %></td>
|
||||
<td><%= event.ip_address %></td>
|
||||
<td>
|
||||
<span class="badge bg-<%= event.blocked? ? 'danger' : event.allowed? ? 'success' : 'warning' %>">
|
||||
<%= event.waf_action %>
|
||||
</span>
|
||||
</td>
|
||||
<td><code><%= event.request_path %></code></td>
|
||||
<td><%= event.response_status %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<%= link_to "View All Events", project_events_path(@project), class: "btn btn-primary btn-sm" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-muted">No events received yet.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyDSN() {
|
||||
const dsnElement = document.querySelector('code');
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = dsnElement.textContent;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
// Show feedback
|
||||
const button = event.target;
|
||||
const originalText = button.textContent;
|
||||
button.textContent = 'Copied!';
|
||||
button.classList.add('btn-success');
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.classList.remove('btn-success');
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,19 @@
|
||||
<% content_for :title, "Rules - #{@project.name}" %>
|
||||
<% content_for :title, "Rules" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Rules</h1>
|
||||
<p class="mt-2 text-gray-600">Manage WAF rules for traffic filtering and control</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<%= link_to "Add Network Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
||||
<%= link_to "Create Rule", new_rule_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Rules</h1>
|
||||
<p class="mt-2 text-gray-600">Manage WAF rules for traffic filtering and control</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<%= link_to "Create Rule", new_rule_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
@@ -93,7 +90,20 @@
|
||||
<!-- Rules List -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">All Rules</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">All Rules (<%= number_with_delimiter(@rules.count) %>)</h3>
|
||||
<% if @pagy.present? && @pagy.pages > 1 %>
|
||||
<span class="text-sm text-gray-500">
|
||||
Page <%= @pagy.page %> of <%= @pagy.pages %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<!-- Top Pagination -->
|
||||
<% if @pagy.present? && @pagy.pages > 1 %>
|
||||
<div class="mt-4">
|
||||
<%= pagy_nav_tailwind(@pagy, pagy_id: 'rules_top') %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @rules.any? %>
|
||||
@@ -178,7 +188,7 @@
|
||||
|
||||
<% if rule.expires_at.present? %>
|
||||
<span class="text-xs text-gray-500" title="Expires at <%= rule.expires_at.strftime('%Y-%m-%d %H:%M') %>">
|
||||
<%= distance_of_time_in_words(Time.current, rule.expires_at) %> left
|
||||
Expires <%= time_ago_in_words(rule.expires_at) %> from now
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -221,4 +231,11 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Pagination -->
|
||||
<% if @pagy.present? && @pagy.pages > 1 %>
|
||||
<div class="mt-6">
|
||||
<%= pagy_nav_tailwind(@pagy, pagy_id: 'rules_bottom') %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
24
app/views/shared/_auth_navigation.html.erb
Normal file
24
app/views/shared/_auth_navigation.html.erb
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- Minimal Navigation Header for Authentication Pages -->
|
||||
<header class="bg-white border-b border-gray-200">
|
||||
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center">
|
||||
<%= link_to root_path, class: "flex-shrink-0 flex items-center" do %>
|
||||
<svg class="h-8 w-8 text-blue-500 mr-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
<span class="text-gray-900 text-lg font-bold">Baffle Hub</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Right side - optional help link -->
|
||||
<div class="flex items-center">
|
||||
<% if User.any? %>
|
||||
<%= link_to "🏠 Back to Dashboard", root_path,
|
||||
class: "text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium transition-colors" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
Reference in New Issue
Block a user