Add 'tags' to event model. Add a dataimport system - currently for MaxMind zip files
This commit is contained in:
@@ -31,3 +31,35 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tom Select overrides for better visibility */
|
||||||
|
.ts-wrapper {
|
||||||
|
visibility: visible !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper .ts-control {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper .ts-control input {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper.multi .ts-control > div {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper.multi .ts-control > div .remove {
|
||||||
|
color: white;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|||||||
131
app/controllers/data_imports_controller.rb
Normal file
131
app/controllers/data_imports_controller.rb
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
class DataImportsController < ApplicationController
|
||||||
|
before_action :require_admin!
|
||||||
|
before_action :set_data_import, only: [:show, :destroy, :progress]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@data_imports = DataImport.all
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
@data_imports = @data_imports.where(import_type: params[:import_type]) if params[:import_type].present?
|
||||||
|
@data_imports = @data_imports.where(status: params[:status]) if params[:status].present?
|
||||||
|
@data_imports = @data_imports.where("filename ILIKE ?", "%#{params[:filename]}%") if params[:filename].present?
|
||||||
|
|
||||||
|
@pagy, @data_imports = pagy(@data_imports.order(created_at: :desc))
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@data_import = DataImport.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
# Save uploaded file and queue import job
|
||||||
|
uploaded_file = params[:data_import][:file]
|
||||||
|
if uploaded_file.nil?
|
||||||
|
@data_import = DataImport.new
|
||||||
|
flash.now[:alert] = "Please select a file to import"
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate file type
|
||||||
|
unless valid_file?(uploaded_file)
|
||||||
|
@data_import = DataImport.new
|
||||||
|
flash.now[:alert] = "Invalid file type. Please upload a .csv or .zip file."
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Determine import type based on filename
|
||||||
|
import_type = detect_import_type_from_filename(uploaded_file.original_filename)
|
||||||
|
|
||||||
|
# Create the DataImport record with the attached file
|
||||||
|
@data_import = DataImport.create!(
|
||||||
|
import_type: import_type,
|
||||||
|
filename: uploaded_file.original_filename,
|
||||||
|
status: 'pending'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach the file using Active Storage
|
||||||
|
@data_import.file.attach(uploaded_file)
|
||||||
|
|
||||||
|
# Queue appropriate import job - pass the entire DataImport object
|
||||||
|
if import_type == 'asn'
|
||||||
|
GeoliteAsnImportJob.perform_later(@data_import)
|
||||||
|
else
|
||||||
|
GeoliteCountryImportJob.perform_later(@data_import)
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to @data_import, notice: "Import has been queued and will begin processing shortly."
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error creating import: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
@data_import = DataImport.new if @data_import.nil?
|
||||||
|
flash.now[:alert] = "Error processing file: #{e.message}"
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
# Show will display import details and progress
|
||||||
|
end
|
||||||
|
|
||||||
|
def progress
|
||||||
|
# JSON endpoint for real-time progress updates
|
||||||
|
render json: {
|
||||||
|
id: @data_import.id,
|
||||||
|
status: @data_import.status,
|
||||||
|
progress_percentage: @data_import.progress_percentage,
|
||||||
|
processed_records: @data_import.processed_records,
|
||||||
|
total_records: @data_import.total_records,
|
||||||
|
failed_records: @data_import.failed_records,
|
||||||
|
duration: @data_import.duration,
|
||||||
|
records_per_second: @data_import.records_per_second,
|
||||||
|
import_stats: @data_import.import_stats,
|
||||||
|
error_message: @data_import.error_message,
|
||||||
|
started_at: @data_import.started_at,
|
||||||
|
completed_at: @data_import.completed_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
if @data_import.processing?
|
||||||
|
redirect_to @data_import, alert: "Cannot delete an import that is currently processing."
|
||||||
|
else
|
||||||
|
@data_import.destroy
|
||||||
|
redirect_to data_imports_path, notice: "Import was successfully deleted."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_data_import
|
||||||
|
@data_import = DataImport.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def data_import_params
|
||||||
|
# No parameters needed since we detect everything automatically
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_file?(uploaded_file)
|
||||||
|
return false unless uploaded_file.respond_to?(:original_filename)
|
||||||
|
|
||||||
|
filename = uploaded_file.original_filename.downcase
|
||||||
|
filename.end_with?('.csv', '.zip')
|
||||||
|
end
|
||||||
|
|
||||||
|
def detect_import_type_from_filename(filename)
|
||||||
|
# Try to detect based on filename first
|
||||||
|
if filename.downcase.include?('asn')
|
||||||
|
'asn'
|
||||||
|
elsif filename.downcase.include?('country')
|
||||||
|
'country'
|
||||||
|
else
|
||||||
|
'country' # Default fallback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_admin!
|
||||||
|
redirect_to root_path, alert: "Access denied. Admin privileges required." unless current_user&.admin?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,8 @@ class EventsController < ApplicationController
|
|||||||
# Apply filters
|
# Apply filters
|
||||||
@events = @events.by_ip(params[:ip]) if params[:ip].present?
|
@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.by_waf_action(params[:waf_action]) if params[:waf_action].present?
|
||||||
@events = @events.where(country_code: params[:country]) if params[:country].present?
|
@events = @events.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.where("network_ranges.country = ?", params[:country]) if params[:country].present?
|
||||||
|
|
||||||
# Network-based filters
|
# Network-based filters
|
||||||
@events = @events.by_company(params[:company]) if params[:company].present?
|
@events = @events.by_company(params[:company]) if params[:company].present?
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||||
import "@hotwired/turbo-rails"
|
import "@hotwired/turbo-rails"
|
||||||
import "controllers"
|
import "controllers"
|
||||||
|
import "tom-select"
|
||||||
|
|||||||
145
app/javascript/controllers/country_selector_controller.js
Normal file
145
app/javascript/controllers/country_selector_controller.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [ "select" ]
|
||||||
|
static values = {
|
||||||
|
options: Array,
|
||||||
|
placeholder: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Check if the element is visible, if not, wait for it to become visible
|
||||||
|
if (this.isHidden()) {
|
||||||
|
// Element is hidden, set up a MutationObserver to watch for visibility changes
|
||||||
|
this.observer = new MutationObserver(() => {
|
||||||
|
if (!this.isHidden()) {
|
||||||
|
this.initializeTomSelect()
|
||||||
|
this.observer.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.observer.observe(this.element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also check periodically as a fallback
|
||||||
|
this.checkInterval = setInterval(() => {
|
||||||
|
if (!this.isHidden()) {
|
||||||
|
this.initializeTomSelect()
|
||||||
|
this.cleanup()
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
} else {
|
||||||
|
// Element is already visible, initialize immediately
|
||||||
|
this.initializeTomSelect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isHidden() {
|
||||||
|
return this.element.offsetParent === null || this.element.classList.contains('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.disconnect()
|
||||||
|
this.observer = null
|
||||||
|
}
|
||||||
|
if (this.checkInterval) {
|
||||||
|
clearInterval(this.checkInterval)
|
||||||
|
this.checkInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeTomSelect() {
|
||||||
|
if (!this.hasSelectTarget) {
|
||||||
|
console.log('No select target found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Tom Select is available
|
||||||
|
if (typeof TomSelect === 'undefined') {
|
||||||
|
console.log('Tom Select is not loaded')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If TomSelect is already initialized, destroy it first
|
||||||
|
if (this.tomSelect) {
|
||||||
|
this.tomSelect.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Initializing Tom Select with options:', this.optionsValue.length, 'countries')
|
||||||
|
console.log('First few country options:', this.optionsValue.slice(0, 3))
|
||||||
|
|
||||||
|
// Prepare options for Tom Select
|
||||||
|
const options = this.optionsValue.map(([display, value]) => ({
|
||||||
|
value: value,
|
||||||
|
text: display,
|
||||||
|
// Add searchable fields for better search
|
||||||
|
search: display + ' ' + value
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get currently selected values from the hidden select
|
||||||
|
const selectedValues = Array.from(this.selectTarget.selectedOptions).map(option => option.value)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize Tom Select
|
||||||
|
this.tomSelect = new TomSelect(this.selectTarget, {
|
||||||
|
options: options,
|
||||||
|
items: selectedValues,
|
||||||
|
plugins: ['remove_button'],
|
||||||
|
maxItems: null,
|
||||||
|
maxOptions: 1000,
|
||||||
|
create: false,
|
||||||
|
placeholder: this.placeholderValue || "Search and select countries...",
|
||||||
|
searchField: ['text', 'search'],
|
||||||
|
searchConjunction: 'or',
|
||||||
|
onItemAdd: function() {
|
||||||
|
// Clear the search input after selecting an item
|
||||||
|
this.setTextboxValue('');
|
||||||
|
this.refreshOptions();
|
||||||
|
},
|
||||||
|
render: {
|
||||||
|
option: function(data, escape) {
|
||||||
|
return `<div class="flex items-center p-2">
|
||||||
|
<span>${escape(data.text)}</span>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
item: function(data, escape) {
|
||||||
|
return `<div class="flex items-center text-sm">
|
||||||
|
<span>${escape(data.text)}</span>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dropdownParent: 'body',
|
||||||
|
copyClassesToDropdown: false
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Tom Select successfully initialized for country selector')
|
||||||
|
|
||||||
|
// Make sure the wrapper is visible
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.tomSelect && this.tomSelect.wrapper) {
|
||||||
|
this.tomSelect.wrapper.style.visibility = 'visible'
|
||||||
|
this.tomSelect.wrapper.style.display = 'block'
|
||||||
|
console.log('Tom Select wrapper made visible')
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing Tom Select:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method to reinitialize if needed
|
||||||
|
reinitialize() {
|
||||||
|
this.initializeTomSelect()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.cleanup()
|
||||||
|
if (this.tomSelect) {
|
||||||
|
this.tomSelect.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["progressBar", "totalRecords", "processedRecords", "failedRecords", "recordsPerSecond"]
|
||||||
|
static values = {
|
||||||
|
importId: Number,
|
||||||
|
refreshInterval: { type: Number, default: 2000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.hasImportIdValue) {
|
||||||
|
this.startUpdating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.stopUpdating()
|
||||||
|
}
|
||||||
|
|
||||||
|
startUpdating() {
|
||||||
|
this.updateProgress()
|
||||||
|
this.interval = setInterval(() => {
|
||||||
|
this.updateProgress()
|
||||||
|
}, this.refreshIntervalValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopUpdating() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProgress() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/data_imports/${this.importIdValue}/progress`)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
this.updateProgressBar(data.progress_percentage)
|
||||||
|
this.updateStats(data)
|
||||||
|
|
||||||
|
// If completed or failed, reload the page
|
||||||
|
if (data.status === 'completed' || data.status === 'failed') {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload()
|
||||||
|
}, 2000)
|
||||||
|
this.stopUpdating()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating progress:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressBar(percentage) {
|
||||||
|
if (this.hasProgressBarTarget) {
|
||||||
|
this.progressBarTarget.style.width = `${percentage}%`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats(data) {
|
||||||
|
if (this.hasTotalRecordsTarget) {
|
||||||
|
this.totalRecordsTarget.textContent = data.total_records.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasProcessedRecordsTarget) {
|
||||||
|
this.processedRecordsTarget.textContent = data.processed_records.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasFailedRecordsTarget) {
|
||||||
|
this.failedRecordsTarget.textContent = data.failed_records.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasRecordsPerSecondTarget) {
|
||||||
|
this.recordsPerSecondTarget.textContent = data.records_per_second.toLocaleString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/jobs/geolite_asn_import_job.rb
Normal file
101
app/jobs/geolite_asn_import_job.rb
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
class GeoliteAsnImportJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# No retry needed for CSV processing - either works or fails immediately
|
||||||
|
|
||||||
|
def perform(data_import)
|
||||||
|
Rails.logger.info "Starting GeoLite ASN import job for DataImport #{data_import.id}"
|
||||||
|
|
||||||
|
# Check if file is attached
|
||||||
|
unless data_import.file.attached?
|
||||||
|
Rails.logger.error "No file attached to DataImport #{data_import.id}"
|
||||||
|
data_import.fail!("No file attached")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Download the file to a temporary location
|
||||||
|
temp_file = download_to_temp_file(data_import.file.blob)
|
||||||
|
if temp_file.nil?
|
||||||
|
Rails.logger.error "Failed to download file from storage"
|
||||||
|
data_import.fail!("Failed to download file from storage")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "File downloaded to: #{temp_file}"
|
||||||
|
Rails.logger.info "File exists: #{File.exist?(temp_file)}"
|
||||||
|
Rails.logger.info "File size: #{File.size(temp_file)} bytes" if File.exist?(temp_file)
|
||||||
|
|
||||||
|
# Mark as processing
|
||||||
|
data_import.start_processing!
|
||||||
|
|
||||||
|
importer = nil
|
||||||
|
begin
|
||||||
|
Rails.logger.info "Creating GeoliteAsnImporter"
|
||||||
|
importer = GeoliteAsnImporter.new(temp_file, data_import: data_import)
|
||||||
|
Rails.logger.info "Calling importer.import"
|
||||||
|
result = importer.import
|
||||||
|
|
||||||
|
# Update final stats
|
||||||
|
data_import.update_progress(
|
||||||
|
processed: result[:processed_records],
|
||||||
|
failed: result[:failed_records],
|
||||||
|
stats: {
|
||||||
|
total_records: result[:total_records],
|
||||||
|
errors: result[:errors].last(10), # Keep last 10 errors
|
||||||
|
completed_at: Time.current
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data_import.complete!
|
||||||
|
|
||||||
|
# Log completion
|
||||||
|
Rails.logger.info "GeoLite ASN import completed: #{result[:processed_records]} processed, #{result[:failed_records]} failed"
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "GeoLite ASN import failed: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
# Update final stats even on failure
|
||||||
|
if importer
|
||||||
|
data_import.update_progress(
|
||||||
|
processed: importer.instance_variable_get(:@processed_records),
|
||||||
|
failed: importer.instance_variable_get(:@failed_records),
|
||||||
|
stats: {
|
||||||
|
total_records: importer.instance_variable_get(:@total_records),
|
||||||
|
current_file: File.basename(temp_file),
|
||||||
|
errors: importer.instance_variable_get(:@errors).last(10),
|
||||||
|
failed_at: Time.current
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
data_import.fail!(e.message)
|
||||||
|
raise
|
||||||
|
ensure
|
||||||
|
# Cleanup temporary files
|
||||||
|
File.delete(temp_file) if temp_file && File.exist?(temp_file)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def download_to_temp_file(blob)
|
||||||
|
# Create a temporary file with the original filename
|
||||||
|
temp_file = Tempfile.new([blob.filename.to_s])
|
||||||
|
temp_file.binmode
|
||||||
|
|
||||||
|
# Download the blob content
|
||||||
|
blob.open do |file|
|
||||||
|
temp_file.write(file.read)
|
||||||
|
end
|
||||||
|
|
||||||
|
temp_file.close
|
||||||
|
temp_file.path
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error downloading file: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
temp_file&.close
|
||||||
|
temp_file&.unlink
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
101
app/jobs/geolite_country_import_job.rb
Normal file
101
app/jobs/geolite_country_import_job.rb
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
class GeoliteCountryImportJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# No retry needed for CSV processing - either works or fails immediately
|
||||||
|
|
||||||
|
def perform(data_import)
|
||||||
|
Rails.logger.info "Starting GeoLite Country import job for DataImport #{data_import.id}"
|
||||||
|
|
||||||
|
# Check if file is attached
|
||||||
|
unless data_import.file.attached?
|
||||||
|
Rails.logger.error "No file attached to DataImport #{data_import.id}"
|
||||||
|
data_import.fail!("No file attached")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Download the file to a temporary location
|
||||||
|
temp_file = download_to_temp_file(data_import.file.blob)
|
||||||
|
if temp_file.nil?
|
||||||
|
Rails.logger.error "Failed to download file from storage"
|
||||||
|
data_import.fail!("Failed to download file from storage")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "File downloaded to: #{temp_file}"
|
||||||
|
Rails.logger.info "File exists: #{File.exist?(temp_file)}"
|
||||||
|
Rails.logger.info "File size: #{File.size(temp_file)} bytes" if File.exist?(temp_file)
|
||||||
|
|
||||||
|
# Mark as processing
|
||||||
|
data_import.start_processing!
|
||||||
|
|
||||||
|
importer = nil
|
||||||
|
begin
|
||||||
|
Rails.logger.info "Creating GeoliteCountryImporter"
|
||||||
|
importer = GeoliteCountryImporter.new(temp_file, data_import: data_import)
|
||||||
|
Rails.logger.info "Calling importer.import"
|
||||||
|
result = importer.import
|
||||||
|
|
||||||
|
# Update final stats
|
||||||
|
data_import.update_progress(
|
||||||
|
processed: result[:processed_records],
|
||||||
|
failed: result[:failed_records],
|
||||||
|
stats: {
|
||||||
|
total_records: result[:total_records],
|
||||||
|
errors: result[:errors].last(10), # Keep last 10 errors
|
||||||
|
completed_at: Time.current
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data_import.complete!
|
||||||
|
|
||||||
|
# Log completion
|
||||||
|
Rails.logger.info "GeoLite Country import completed: #{result[:processed_records]} processed, #{result[:failed_records]} failed"
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "GeoLite Country import failed: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
# Update final stats even on failure
|
||||||
|
if importer
|
||||||
|
data_import.update_progress(
|
||||||
|
processed: importer.instance_variable_get(:@processed_records),
|
||||||
|
failed: importer.instance_variable_get(:@failed_records),
|
||||||
|
stats: {
|
||||||
|
total_records: importer.instance_variable_get(:@total_records),
|
||||||
|
current_file: File.basename(temp_file),
|
||||||
|
errors: importer.instance_variable_get(:@errors).last(10),
|
||||||
|
failed_at: Time.current
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
data_import.fail!(e.message)
|
||||||
|
raise
|
||||||
|
ensure
|
||||||
|
# Cleanup temporary files
|
||||||
|
File.delete(temp_file) if temp_file && File.exist?(temp_file)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def download_to_temp_file(blob)
|
||||||
|
# Create a temporary file with the original filename
|
||||||
|
temp_file = Tempfile.new([blob.filename.to_s])
|
||||||
|
temp_file.binmode
|
||||||
|
|
||||||
|
# Download the blob content
|
||||||
|
blob.open do |file|
|
||||||
|
temp_file.write(file.read)
|
||||||
|
end
|
||||||
|
|
||||||
|
temp_file.close
|
||||||
|
temp_file.path
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error downloading file: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
temp_file&.close
|
||||||
|
temp_file&.unlink
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -44,16 +44,20 @@ class ProcessWafAnalyticsJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def analyze_geographic_distribution(event)
|
def analyze_geographic_distribution(event)
|
||||||
return unless event.country_code.present?
|
return unless event.has_geo_data?
|
||||||
|
|
||||||
# Check if this country is unusual globally
|
country_code = event.lookup_country
|
||||||
|
return unless country_code.present?
|
||||||
|
|
||||||
|
# Check if this country is unusual globally by joining through network ranges
|
||||||
country_events = Event
|
country_events = Event
|
||||||
.where(country_code: event.country_code)
|
.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.where("network_ranges.country = ?", country_code)
|
||||||
.where(timestamp: 1.hour.ago..Time.current)
|
.where(timestamp: 1.hour.ago..Time.current)
|
||||||
|
|
||||||
# If this is the first event from this country or unusual spike
|
# If this is the first event from this country or unusual spike
|
||||||
if country_events.count == 1 || country_events.count > 100
|
if country_events.count == 1 || country_events.count > 100
|
||||||
Rails.logger.info "Unusual geographic activity from #{event.country_code}"
|
Rails.logger.info "Unusual geographic activity from #{country_code}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ class ProcessWafEventJob < ApplicationJob
|
|||||||
# Create the WAF event record
|
# Create the WAF event record
|
||||||
event = Event.create_from_waf_payload!(event_id, single_event_data)
|
event = Event.create_from_waf_payload!(event_id, single_event_data)
|
||||||
|
|
||||||
# Enrich with geo-location data if missing
|
# Log geo-location data status (uses NetworkRange delegation)
|
||||||
if event.ip_address.present? && event.country_code.blank?
|
if event.ip_address.present?
|
||||||
begin
|
begin
|
||||||
event.enrich_geo_location!
|
unless event.has_geo_data?
|
||||||
|
Rails.logger.debug "No geo data available for event #{event.id} with IP #{event.ip_address}"
|
||||||
|
end
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.warn "Failed to enrich geo location for event #{event.id}: #{e.message}"
|
Rails.logger.warn "Failed to check geo data for event #{event.id}: #{e.message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
class ProcessWafPoliciesJob < ApplicationJob
|
class ProcessWafPoliciesJob < ApplicationJob
|
||||||
queue_as :waf_policies
|
queue_as :waf_policies
|
||||||
|
|
||||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
retry_on StandardError, wait: 5.seconds, attempts: 3
|
||||||
|
|
||||||
def perform(network_range_id:, event_id: nil)
|
def perform(network_range_id:, event_id: nil)
|
||||||
# Find the network range
|
# Find the network range
|
||||||
|
|||||||
96
app/models/data_import.rb
Normal file
96
app/models/data_import.rb
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
class DataImport < ApplicationRecord
|
||||||
|
has_one_attached :file
|
||||||
|
|
||||||
|
validates :import_type, presence: true, inclusion: { in: %w[asn country] }
|
||||||
|
validates :status, presence: true, inclusion: { in: %w[pending processing completed failed] }
|
||||||
|
validates :filename, presence: true
|
||||||
|
|
||||||
|
attribute :import_stats, default: -> { {} }
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :recent, -> { order(created_at: :desc) }
|
||||||
|
scope :by_type, ->(type) { where(import_type: type) }
|
||||||
|
scope :by_status, ->(status) { where(status: status) }
|
||||||
|
scope :completed, -> { where(status: 'completed') }
|
||||||
|
scope :failed, -> { where(status: 'failed') }
|
||||||
|
scope :processing, -> { where(status: 'processing') }
|
||||||
|
scope :pending, -> { where(status: 'pending') }
|
||||||
|
|
||||||
|
# State management
|
||||||
|
def pending?
|
||||||
|
status == 'pending'
|
||||||
|
end
|
||||||
|
|
||||||
|
def processing?
|
||||||
|
status == 'processing'
|
||||||
|
end
|
||||||
|
|
||||||
|
def completed?
|
||||||
|
status == 'completed'
|
||||||
|
end
|
||||||
|
|
||||||
|
def failed?
|
||||||
|
status == 'failed'
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_processing!
|
||||||
|
update!(
|
||||||
|
status: 'processing',
|
||||||
|
started_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete!
|
||||||
|
updates = {
|
||||||
|
status: 'completed',
|
||||||
|
completed_at: Time.current
|
||||||
|
}
|
||||||
|
updates[:total_records] = processed_records if total_records.zero?
|
||||||
|
update!(updates)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fail!(error_message = nil)
|
||||||
|
update!(
|
||||||
|
status: 'failed',
|
||||||
|
completed_at: Time.current,
|
||||||
|
error_message: error_message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def progress_percentage
|
||||||
|
if total_records.zero?
|
||||||
|
processing? ? 0.1 : 0 # Show minimal progress for processing jobs
|
||||||
|
else
|
||||||
|
(processed_records.to_f / total_records * 100).round(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def duration
|
||||||
|
return 0 unless started_at
|
||||||
|
end_time = completed_at || Time.current
|
||||||
|
duration_seconds = (end_time - started_at).round(2)
|
||||||
|
duration_seconds.negative? ? 0 : duration_seconds
|
||||||
|
end
|
||||||
|
|
||||||
|
def records_per_second
|
||||||
|
# Handle very fast imports that complete in less than 1 second
|
||||||
|
if duration.zero?
|
||||||
|
# Use time since started if no duration available yet
|
||||||
|
time_elapsed = started_at ? (Time.current - started_at) : 0
|
||||||
|
return 0 if time_elapsed < 1
|
||||||
|
(processed_records.to_f / time_elapsed).round(2)
|
||||||
|
else
|
||||||
|
(processed_records.to_f / duration).round(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_progress(processed: nil, failed: nil, total_records: nil, stats: nil)
|
||||||
|
updates = {}
|
||||||
|
updates[:processed_records] = processed if processed
|
||||||
|
updates[:failed_records] = failed if failed
|
||||||
|
updates[:total_records] = total_records if total_records
|
||||||
|
updates[:import_stats] = stats if stats
|
||||||
|
|
||||||
|
update!(updates) if updates.any?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -25,6 +25,10 @@ class Event < ApplicationRecord
|
|||||||
# Serialize segment IDs as array for easy manipulation in Railssqit
|
# Serialize segment IDs as array for easy manipulation in Railssqit
|
||||||
serialize :request_segment_ids, type: Array, coder: JSON
|
serialize :request_segment_ids, type: Array, coder: JSON
|
||||||
|
|
||||||
|
# Tags are stored as JSON arrays with PostgreSQL jsonb type
|
||||||
|
# This provides direct array access and efficient indexing
|
||||||
|
attribute :tags, :json, default: -> { [] }
|
||||||
|
|
||||||
validates :event_id, presence: true, uniqueness: true
|
validates :event_id, presence: true, uniqueness: true
|
||||||
validates :timestamp, presence: true
|
validates :timestamp, presence: true
|
||||||
|
|
||||||
@@ -36,6 +40,21 @@ class Event < ApplicationRecord
|
|||||||
scope :allowed, -> { where(waf_action: :allow) }
|
scope :allowed, -> { where(waf_action: :allow) }
|
||||||
scope :rate_limited, -> { where(waf_action: 'rate_limit') }
|
scope :rate_limited, -> { where(waf_action: 'rate_limit') }
|
||||||
|
|
||||||
|
# Tag-based filtering scopes using PostgreSQL array operators
|
||||||
|
scope :with_tag, ->(tag) { where("tags @> ARRAY[?]", tag.to_s) }
|
||||||
|
|
||||||
|
scope :with_any_tags, ->(tags) {
|
||||||
|
return none if tags.blank?
|
||||||
|
tag_array = Array(tags).map(&:to_s)
|
||||||
|
where("tags && ARRAY[?]", tag_array)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope :with_all_tags, ->(tags) {
|
||||||
|
return none if tags.blank?
|
||||||
|
tag_array = Array(tags).map(&:to_s)
|
||||||
|
where("tags @> ARRAY[?]", tag_array)
|
||||||
|
}
|
||||||
|
|
||||||
# Network-based filtering scopes
|
# Network-based filtering scopes
|
||||||
scope :by_company, ->(company) {
|
scope :by_company, ->(company) {
|
||||||
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
@@ -234,7 +253,8 @@ class Event < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def tags
|
def tags
|
||||||
payload&.dig("tags") || {}
|
# Use the dedicated tags column (array), fallback to payload during transition
|
||||||
|
super.presence || (payload&.dig("tags") || [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def headers
|
def headers
|
||||||
@@ -281,6 +301,25 @@ class Event < ApplicationRecord
|
|||||||
URI.parse(request_url).hostname rescue nil
|
URI.parse(request_url).hostname rescue nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Tag helper methods
|
||||||
|
def add_tag(tag)
|
||||||
|
tag_str = tag.to_s
|
||||||
|
self.tags = (tags + [tag_str]).uniq unless tags.include?(tag_str)
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_tag(tag)
|
||||||
|
tag_str = tag.to_s
|
||||||
|
self.tags = tags - [tag_str] if tags.include?(tag_str)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_tag?(tag)
|
||||||
|
tags.include?(tag.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_list
|
||||||
|
tags.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
# Normalize headers to lower case keys during import phase
|
# Normalize headers to lower case keys during import phase
|
||||||
def normalize_headers(headers)
|
def normalize_headers(headers)
|
||||||
return {} unless headers.is_a?(Hash)
|
return {} unless headers.is_a?(Hash)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# and classification flags (datacenter, proxy, VPN).
|
# and classification flags (datacenter, proxy, VPN).
|
||||||
class NetworkRange < ApplicationRecord
|
class NetworkRange < ApplicationRecord
|
||||||
# Sources for network range creation
|
# Sources for network range creation
|
||||||
SOURCES = %w[api_imported user_created manual auto_generated inherited].freeze
|
SOURCES = %w[api_imported user_created manual auto_generated inherited geolite_asn geolite_country].freeze
|
||||||
|
|
||||||
# Associations
|
# Associations
|
||||||
has_many :rules, dependent: :destroy
|
has_many :rules, dependent: :destroy
|
||||||
@@ -29,6 +29,9 @@ class NetworkRange < ApplicationRecord
|
|||||||
scope :vpn, -> { where(is_vpn: true) }
|
scope :vpn, -> { where(is_vpn: true) }
|
||||||
scope :user_created, -> { where(source: 'user_created') }
|
scope :user_created, -> { where(source: 'user_created') }
|
||||||
scope :api_imported, -> { where(source: 'api_imported') }
|
scope :api_imported, -> { where(source: 'api_imported') }
|
||||||
|
scope :geolite_imported, -> { where(source: ['geolite_asn', 'geolite_country']) }
|
||||||
|
scope :geolite_asn, -> { where(source: 'geolite_asn') }
|
||||||
|
scope :geolite_country, -> { where(source: 'geolite_country') }
|
||||||
scope :with_events, -> { where("events_count > 0") }
|
scope :with_events, -> { where("events_count > 0") }
|
||||||
scope :most_active, -> { order(events_count: :desc) }
|
scope :most_active, -> { order(events_count: :desc) }
|
||||||
|
|
||||||
@@ -295,4 +298,44 @@ class NetworkRange < ApplicationRecord
|
|||||||
# The inherited_intelligence method will pick up the new parent data
|
# The inherited_intelligence method will pick up the new parent data
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Import-related class methods
|
||||||
|
def self.import_stats_by_source
|
||||||
|
group(:source)
|
||||||
|
.select(:source, 'COUNT(*) as count', 'MIN(created_at) as first_import', 'MAX(updated_at) as last_update')
|
||||||
|
.order(:source)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.geolite_coverage_stats
|
||||||
|
{
|
||||||
|
total_networks: geolite_imported.count,
|
||||||
|
asn_networks: geolite_asn.count,
|
||||||
|
country_networks: geolite_country.count,
|
||||||
|
with_asn_data: geolite_imported.where.not(asn: nil).count,
|
||||||
|
with_country_data: geolite_imported.where.not(country: nil).count,
|
||||||
|
with_proxy_data: geolite_imported.where(is_proxy: true).count,
|
||||||
|
unique_countries: geolite_imported.distinct.count(:country),
|
||||||
|
unique_asns: geolite_imported.distinct.count(:asn),
|
||||||
|
ipv4_networks: geolite_imported.ipv4.count,
|
||||||
|
ipv6_networks: geolite_imported.ipv6.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.find_by_ip_or_network(query)
|
||||||
|
return none if query.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Try to parse as IP address first
|
||||||
|
ip = IPAddr.new(query)
|
||||||
|
where("network >>= ?", ip.to_s)
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
# Try to parse as network
|
||||||
|
begin
|
||||||
|
network = IPAddr.new(query)
|
||||||
|
where(network: network.to_s)
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
none
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -122,7 +122,7 @@ validate :targets_must_be_array
|
|||||||
network_range: network_range,
|
network_range: network_range,
|
||||||
waf_policy: self,
|
waf_policy: self,
|
||||||
user: user,
|
user: user,
|
||||||
source: "policy:#{name}",
|
source: "policy",
|
||||||
metadata: build_rule_metadata(network_range),
|
metadata: build_rule_metadata(network_range),
|
||||||
priority: network_range.prefix_length
|
priority: network_range.prefix_length
|
||||||
)
|
)
|
||||||
|
|||||||
58
app/policies/waf_policy_policy.rb
Normal file
58
app/policies/waf_policy_policy.rb
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class WafPolicyPolicy < ApplicationPolicy
|
||||||
|
def index?
|
||||||
|
true # All authenticated users can view policies
|
||||||
|
end
|
||||||
|
|
||||||
|
def show?
|
||||||
|
true # All authenticated users can view policy details
|
||||||
|
end
|
||||||
|
|
||||||
|
def new?
|
||||||
|
user.admin? || user.editor?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create?
|
||||||
|
user.admin? || user.editor?
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit?
|
||||||
|
user.admin? || (user.editor? && record.user == user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update?
|
||||||
|
user.admin? || (user.editor? && record.user == user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
user.admin? || (user.editor? && record.user == user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def activate?
|
||||||
|
user.admin? || (user.editor? && record.user == user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def deactivate?
|
||||||
|
user.admin? || (user.editor? && record.user == user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_country?
|
||||||
|
create?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_country?
|
||||||
|
create?
|
||||||
|
end
|
||||||
|
|
||||||
|
class Scope < ApplicationPolicy::Scope
|
||||||
|
def resolve
|
||||||
|
if user.admin?
|
||||||
|
scope.all
|
||||||
|
else
|
||||||
|
# Non-admin users can only see their own policies
|
||||||
|
scope.where(user: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
151
app/services/country_helper.rb
Normal file
151
app/services/country_helper.rb
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# CountryHelper - Service for country display utilities
|
||||||
|
#
|
||||||
|
# Provides methods to convert ISO country codes to display names,
|
||||||
|
# generate country flags, and format country data for UI components.
|
||||||
|
class CountryHelper
|
||||||
|
# Convert ISO code to display name
|
||||||
|
def self.display_name(iso_code)
|
||||||
|
return iso_code if iso_code.blank?
|
||||||
|
|
||||||
|
country = ISO3166::Country[iso_code]
|
||||||
|
country.local_name
|
||||||
|
rescue
|
||||||
|
iso_code
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convert ISO code to flag emoji
|
||||||
|
def self.flag_emoji(iso_code)
|
||||||
|
return "" if iso_code.blank? || iso_code.length != 2
|
||||||
|
|
||||||
|
# Convert each letter to regional indicator symbol (A + 0x1F1E5)
|
||||||
|
iso_code.upcase.codepoints.map { |code| (code + 0x1F1E5).chr }.join
|
||||||
|
rescue
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display name with flag
|
||||||
|
def self.display_with_flag(iso_code)
|
||||||
|
return iso_code if iso_code.blank?
|
||||||
|
|
||||||
|
"#{display_name(iso_code)} (#{iso_code})"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if ISO code is valid
|
||||||
|
def self.valid_iso_code?(iso_code)
|
||||||
|
return false if iso_code.blank?
|
||||||
|
|
||||||
|
ISO3166::Country[iso_code].present?
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get all countries for select dropdowns
|
||||||
|
# Returns array of [display_name, iso_code] pairs
|
||||||
|
def self.all_for_select
|
||||||
|
ISO3166::Country.all.map do |country|
|
||||||
|
# Try different name sources in order of preference
|
||||||
|
# Use the proper countries gem methods
|
||||||
|
name = country.local_name.presence ||
|
||||||
|
country.iso_short_name.presence ||
|
||||||
|
country.common_name.presence ||
|
||||||
|
country.alpha2
|
||||||
|
display_name = "#{name} (#{country.alpha2})"
|
||||||
|
[display_name, country.alpha2]
|
||||||
|
end.sort_by { |name, _| name }
|
||||||
|
rescue => e
|
||||||
|
puts "Error in CountryHelper.all_for_select: #{e.message}"
|
||||||
|
puts e.backtrace
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
# Get countries by common regions for quick selection
|
||||||
|
def self.by_region
|
||||||
|
{
|
||||||
|
'Americas' => [
|
||||||
|
'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO', 'PE', 'VE'
|
||||||
|
],
|
||||||
|
'Europe' => [
|
||||||
|
'GB', 'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'CH', 'AT', 'SE',
|
||||||
|
'NO', 'DK', 'FI', 'PL', 'CZ', 'HU', 'RO', 'GR', 'PT'
|
||||||
|
],
|
||||||
|
'Asia Pacific' => [
|
||||||
|
'CN', 'JP', 'KR', 'IN', 'SG', 'AU', 'NZ', 'TH', 'MY', 'ID',
|
||||||
|
'PH', 'VN', 'HK', 'TW'
|
||||||
|
],
|
||||||
|
'Middle East & Africa' => [
|
||||||
|
'ZA', 'EG', 'NG', 'KE', 'SA', 'AE', 'IL', 'TR', 'IR'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
rescue
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get countries for specific region with display names
|
||||||
|
def self.countries_for_region(region_name)
|
||||||
|
country_codes = by_region[region_name] || []
|
||||||
|
|
||||||
|
country_codes.map do |code|
|
||||||
|
{
|
||||||
|
code: code,
|
||||||
|
name: display_name(code),
|
||||||
|
display: display_with_flag(code)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Format multiple country targets for display
|
||||||
|
def self.format_targets(targets)
|
||||||
|
return [] if targets.blank?
|
||||||
|
|
||||||
|
targets.map do |target|
|
||||||
|
{
|
||||||
|
code: target,
|
||||||
|
name: display_name(target),
|
||||||
|
display: display_with_flag(target)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get popular countries for quick blocking (common threat sources)
|
||||||
|
def self.popular_for_blocking
|
||||||
|
[
|
||||||
|
{ code: 'CN', name: 'China', display: '🇨🇳 China', reason: 'High bot/scanner activity' },
|
||||||
|
{ code: 'RU', name: 'Russia', display: '🇷🇺 Russia', reason: 'State-sponsored attacks' },
|
||||||
|
{ code: 'IN', name: 'India', display: '🇮🇳 India', reason: 'High spam volume' },
|
||||||
|
{ code: 'BR', name: 'Brazil', display: '🇧🇷 Brazil', reason: 'Scanner activity' },
|
||||||
|
{ code: 'IR', name: 'Iran', display: '🇮🇷 Iran', reason: 'Attacks on critical infrastructure' },
|
||||||
|
{ code: 'KP', name: 'North Korea', display: '🇰🇵 North Korea', reason: 'State-sponsored hacking' }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Search countries by name or code
|
||||||
|
def self.search(query)
|
||||||
|
return [] if query.blank?
|
||||||
|
|
||||||
|
query = query.downcase
|
||||||
|
ISO3166::Country.all.select do |country|
|
||||||
|
country.alpha2.downcase.include?(query) ||
|
||||||
|
country.local_name.downcase.include?(query)
|
||||||
|
end.first(20).map { |c| [display_with_flag(c.alpha2), c.alpha2] }
|
||||||
|
rescue
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Country statistics for analytics
|
||||||
|
def self.usage_statistics(country_codes)
|
||||||
|
return {} if country_codes.blank?
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
country_codes.each do |code|
|
||||||
|
stats[code] = {
|
||||||
|
name: display_name(code),
|
||||||
|
flag: flag_emoji(code),
|
||||||
|
display: display_with_flag(code)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
stats
|
||||||
|
end
|
||||||
|
end
|
||||||
182
app/services/geolite_asn_importer.rb
Normal file
182
app/services/geolite_asn_importer.rb
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
require 'csv'
|
||||||
|
|
||||||
|
class GeoliteAsnImporter
|
||||||
|
BATCH_SIZE = 1000
|
||||||
|
|
||||||
|
def initialize(file_path, data_import:)
|
||||||
|
@file_path = file_path
|
||||||
|
@data_import = data_import
|
||||||
|
@total_records = 0
|
||||||
|
@processed_records = 0
|
||||||
|
@failed_records = 0
|
||||||
|
@errors = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def import
|
||||||
|
Rails.logger.info "Starting import for file: #{@file_path}"
|
||||||
|
Rails.logger.info "File exists: #{File.exist?(@file_path)}"
|
||||||
|
Rails.logger.info "File size: #{File.size(@file_path)} bytes" if File.exist?(@file_path)
|
||||||
|
|
||||||
|
# Check if file is actually a zip by reading the magic bytes
|
||||||
|
is_zip_file = check_if_zip_file
|
||||||
|
Rails.logger.info "File is zip: #{is_zip_file}"
|
||||||
|
|
||||||
|
if is_zip_file
|
||||||
|
import_from_zip
|
||||||
|
else
|
||||||
|
import_csv_file(@file_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
total_records: @total_records,
|
||||||
|
processed_records: @processed_records,
|
||||||
|
failed_records: @failed_records,
|
||||||
|
errors: @errors
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_if_zip_file
|
||||||
|
# Check if the file starts with ZIP magic bytes (PK\x03\x04)
|
||||||
|
File.open(@file_path, 'rb') do |file|
|
||||||
|
header = file.read(4)
|
||||||
|
return header == "PK\x03\x04"
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error checking if file is zip: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_from_zip
|
||||||
|
require 'zip'
|
||||||
|
require 'stringio'
|
||||||
|
|
||||||
|
Rails.logger.info "Processing zip file directly: #{@file_path}"
|
||||||
|
|
||||||
|
# Read the entire ZIP file content into memory first
|
||||||
|
zip_content = File.binread(@file_path)
|
||||||
|
|
||||||
|
Zip::File.open_buffer(StringIO.new(zip_content)) do |zip_file|
|
||||||
|
zip_file.each do |entry|
|
||||||
|
if entry.name.include?('Blocks') && entry.name.end_with?('.csv')
|
||||||
|
Rails.logger.info "Processing ASN block file from zip: #{entry.name}"
|
||||||
|
process_csv_from_zip(zip_file, entry)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error processing ZIP file: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_csv_from_zip(zip_file, entry)
|
||||||
|
zip_file.get_input_stream(entry) do |io|
|
||||||
|
# Read the entire content from the stream
|
||||||
|
content = io.read
|
||||||
|
|
||||||
|
CSV.parse(content, headers: true, header_converters: :symbol, encoding: 'UTF-8') do |row|
|
||||||
|
@total_records += 1
|
||||||
|
|
||||||
|
begin
|
||||||
|
import_record(row)
|
||||||
|
@processed_records += 1
|
||||||
|
rescue => e
|
||||||
|
@failed_records += 1
|
||||||
|
@errors << "Row #{@total_records}: #{e.message} - Data: #{row.to_h}"
|
||||||
|
end
|
||||||
|
|
||||||
|
update_progress_if_needed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_files
|
||||||
|
if @file_path.end_with?('.zip')
|
||||||
|
# Look for extracted CSV files in the same directory
|
||||||
|
base_dir = File.dirname(@file_path)
|
||||||
|
base_name = File.basename(@file_path, '.zip')
|
||||||
|
|
||||||
|
[
|
||||||
|
File.join(base_dir, "#{base_name}-Blocks-IPv4.csv"),
|
||||||
|
File.join(base_dir, "#{base_name}-Blocks-IPv6.csv")
|
||||||
|
].select { |file| File.exist?(file) }
|
||||||
|
else
|
||||||
|
[@file_path]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_csv_file(csv_file)
|
||||||
|
CSV.foreach(csv_file, headers: true, header_converters: :symbol, encoding: 'UTF-8') do |row|
|
||||||
|
@total_records += 1
|
||||||
|
|
||||||
|
begin
|
||||||
|
import_record(row)
|
||||||
|
@processed_records += 1
|
||||||
|
rescue => e
|
||||||
|
@failed_records += 1
|
||||||
|
@errors << "Row #{@total_records}: #{e.message} - Data: #{row.to_h}"
|
||||||
|
|
||||||
|
# Update progress every 100 records or on error
|
||||||
|
update_progress_if_needed
|
||||||
|
end
|
||||||
|
|
||||||
|
update_progress_if_needed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_record(row)
|
||||||
|
network = row[:network]
|
||||||
|
asn = row[:autonomous_system_number]&.to_i
|
||||||
|
asn_org = row[:autonomous_system_organization]&.strip
|
||||||
|
|
||||||
|
unless network && asn && asn_org
|
||||||
|
raise "Missing required fields: network=#{network}, asn=#{asn}, asn_org=#{asn_org}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate network format
|
||||||
|
IPAddr.new(network) # This will raise if invalid
|
||||||
|
|
||||||
|
NetworkRange.upsert(
|
||||||
|
{
|
||||||
|
network: network,
|
||||||
|
asn: asn,
|
||||||
|
asn_org: asn_org,
|
||||||
|
source: 'geolite_asn',
|
||||||
|
updated_at: Time.current
|
||||||
|
},
|
||||||
|
unique_by: :index_network_ranges_on_network_unique
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_progress_if_needed
|
||||||
|
if (@processed_records + @failed_records) % 100 == 0
|
||||||
|
@data_import.update_progress(
|
||||||
|
processed: @processed_records,
|
||||||
|
failed: @failed_records,
|
||||||
|
total_records: @total_records,
|
||||||
|
stats: {
|
||||||
|
total_records: @total_records,
|
||||||
|
current_file: File.basename(@file_path),
|
||||||
|
recent_errors: @errors.last(5)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_if_zipfile
|
||||||
|
return unless @file_path.end_with?('.zip')
|
||||||
|
|
||||||
|
require 'zip'
|
||||||
|
|
||||||
|
Zip::File.open(@file_path) do |zip_file|
|
||||||
|
zip_file.each do |entry|
|
||||||
|
if entry.name.end_with?('.csv')
|
||||||
|
extract_path = File.join(File.dirname(@file_path), entry.name)
|
||||||
|
entry.extract(extract_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
288
app/services/geolite_country_importer.rb
Normal file
288
app/services/geolite_country_importer.rb
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
require 'csv'
|
||||||
|
|
||||||
|
class GeoliteCountryImporter
|
||||||
|
BATCH_SIZE = 1000
|
||||||
|
|
||||||
|
def initialize(file_path, data_import:)
|
||||||
|
@file_path = file_path
|
||||||
|
@data_import = data_import
|
||||||
|
@total_records = 0
|
||||||
|
@processed_records = 0
|
||||||
|
@failed_records = 0
|
||||||
|
@errors = []
|
||||||
|
@locations_cache = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def import
|
||||||
|
Rails.logger.info "Starting import for file: #{@file_path}"
|
||||||
|
Rails.logger.info "File exists: #{File.exist?(@file_path)}"
|
||||||
|
Rails.logger.info "File size: #{File.size(@file_path)} bytes" if File.exist?(@file_path)
|
||||||
|
|
||||||
|
# Check if file is actually a zip by reading the magic bytes
|
||||||
|
is_zip_file = check_if_zip_file
|
||||||
|
Rails.logger.info "File is zip: #{is_zip_file}"
|
||||||
|
|
||||||
|
if is_zip_file
|
||||||
|
Rails.logger.info "Calling import_from_zip"
|
||||||
|
import_from_zip
|
||||||
|
else
|
||||||
|
Rails.logger.info "Calling regular import (not zip)"
|
||||||
|
load_locations_data
|
||||||
|
import_csv_file(@file_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
total_records: @total_records,
|
||||||
|
processed_records: @processed_records,
|
||||||
|
failed_records: @failed_records,
|
||||||
|
errors: @errors
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_if_zip_file
|
||||||
|
# Check if the file starts with ZIP magic bytes (PK\x03\x04)
|
||||||
|
File.open(@file_path, 'rb') do |file|
|
||||||
|
header = file.read(4)
|
||||||
|
return header == "PK\x03\x04"
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error checking if file is zip: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_from_zip
|
||||||
|
require 'zip'
|
||||||
|
require 'stringio'
|
||||||
|
|
||||||
|
Rails.logger.info "Processing zip file directly: #{@file_path}"
|
||||||
|
|
||||||
|
# Read the entire ZIP file content into memory first
|
||||||
|
zip_content = File.binread(@file_path)
|
||||||
|
|
||||||
|
Zip::File.open_buffer(StringIO.new(zip_content)) do |zip_file|
|
||||||
|
# First, see what's in the zip
|
||||||
|
Rails.logger.info "Files in zip:"
|
||||||
|
zip_file.each do |entry|
|
||||||
|
Rails.logger.info " - #{entry.name} (#{entry.size} bytes)"
|
||||||
|
end
|
||||||
|
|
||||||
|
# First, load location data from zip
|
||||||
|
load_locations_data_from_zip(zip_file)
|
||||||
|
|
||||||
|
# Then process block files from zip
|
||||||
|
zip_file.each do |entry|
|
||||||
|
if entry.name.include?('Blocks') && entry.name.end_with?('.csv')
|
||||||
|
Rails.logger.info "Processing block file from zip: #{entry.name}"
|
||||||
|
process_csv_from_zip(zip_file, entry)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error processing ZIP file: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_csv_from_zip(zip_file, entry)
|
||||||
|
zip_file.get_input_stream(entry) do |io|
|
||||||
|
# Read the entire content from the stream
|
||||||
|
content = io.read
|
||||||
|
|
||||||
|
# Try different encodings if UTF-8 fails
|
||||||
|
encodings = ['UTF-8', 'ISO-8859-1', 'Windows-1252']
|
||||||
|
encoding_used = nil
|
||||||
|
|
||||||
|
encodings.each do |encoding|
|
||||||
|
begin
|
||||||
|
# Parse the CSV content from the string
|
||||||
|
CSV.parse(content, headers: true, header_converters: :symbol, encoding: encoding) do |row|
|
||||||
|
@total_records += 1
|
||||||
|
|
||||||
|
begin
|
||||||
|
import_record(row)
|
||||||
|
@processed_records += 1
|
||||||
|
rescue => e
|
||||||
|
@failed_records += 1
|
||||||
|
@errors << "Row #{@total_records}: #{e.message} - Data: #{row.to_h}"
|
||||||
|
end
|
||||||
|
|
||||||
|
update_progress_if_needed
|
||||||
|
end
|
||||||
|
encoding_used = encoding
|
||||||
|
Rails.logger.info "Successfully processed #{entry.name} with #{encoding} encoding"
|
||||||
|
break
|
||||||
|
rescue CSV::InvalidEncodingError => e
|
||||||
|
Rails.logger.warn "Failed to process #{entry.name} with #{encoding} encoding: #{e.message}"
|
||||||
|
next if encoding != encodings.last
|
||||||
|
raise e if encoding == encodings.last
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
unless encoding_used
|
||||||
|
@errors << "Failed to process #{entry.name} with any supported encoding"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_locations_data_from_zip(zip_file)
|
||||||
|
require 'zip'
|
||||||
|
|
||||||
|
# Find all location files and prioritize English
|
||||||
|
location_entries = zip_file.select { |entry| entry.name.include?('Locations') && entry.name.end_with?('.csv') }
|
||||||
|
|
||||||
|
# Sort to prioritize English locations file
|
||||||
|
location_entries.sort_by! { |entry| entry.name.include?('Locations-en') ? 0 : 1 }
|
||||||
|
|
||||||
|
location_entries.each do |entry|
|
||||||
|
Rails.logger.info "Loading locations from: #{entry.name}"
|
||||||
|
zip_file.get_input_stream(entry) do |io|
|
||||||
|
# Read the entire content from the stream
|
||||||
|
content = io.read
|
||||||
|
|
||||||
|
# Try different encodings if UTF-8 fails
|
||||||
|
encodings = ['UTF-8', 'ISO-8859-1', 'Windows-1252']
|
||||||
|
|
||||||
|
encodings.each do |encoding|
|
||||||
|
begin
|
||||||
|
# Parse the CSV content from the string
|
||||||
|
CSV.parse(content, headers: true, header_converters: :symbol, encoding: encoding) do |row|
|
||||||
|
geoname_id = row[:geoname_id]
|
||||||
|
next unless geoname_id
|
||||||
|
|
||||||
|
@locations_cache[geoname_id] = {
|
||||||
|
country_iso_code: row[:country_iso_code],
|
||||||
|
country_name: row[:country_name],
|
||||||
|
continent_code: row[:continent_code],
|
||||||
|
continent_name: row[:continent_name],
|
||||||
|
is_in_european_union: row[:is_in_european_union]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
Rails.logger.info "Loaded locations from #{entry.name} with #{encoding} encoding"
|
||||||
|
break
|
||||||
|
rescue CSV::InvalidEncodingError => e
|
||||||
|
Rails.logger.warn "Failed to load locations from #{entry.name} with #{encoding} encoding: #{e.message}"
|
||||||
|
next if encoding != encodings.last
|
||||||
|
raise e if encoding == encodings.last
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Loaded #{@locations_cache.size} location records"
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_csv_file(csv_file)
|
||||||
|
CSV.foreach(csv_file, headers: true, header_converters: :symbol, encoding: 'UTF-8') do |row|
|
||||||
|
@total_records += 1
|
||||||
|
|
||||||
|
begin
|
||||||
|
import_record(row)
|
||||||
|
@processed_records += 1
|
||||||
|
rescue => e
|
||||||
|
@failed_records += 1
|
||||||
|
@errors << "Row #{@total_records}: #{e.message} - Data: #{row.to_h}"
|
||||||
|
|
||||||
|
# Update progress every 100 records or on error
|
||||||
|
update_progress_if_needed
|
||||||
|
end
|
||||||
|
|
||||||
|
update_progress_if_needed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_record(row)
|
||||||
|
network = row[:network]
|
||||||
|
geoname_id = row[:geoname_id]
|
||||||
|
registered_country_geoname_id = row[:registered_country_geoname_id]
|
||||||
|
is_anonymous_proxy = row[:is_anonymous_proxy] == '1'
|
||||||
|
is_satellite_provider = row[:is_satellite_provider] == '1'
|
||||||
|
is_anycast = row[:is_anycast] == '1'
|
||||||
|
|
||||||
|
unless network
|
||||||
|
raise "Missing required field: network"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate network format
|
||||||
|
IPAddr.new(network) # This will raise if invalid
|
||||||
|
|
||||||
|
# Get location data - prefer geoname_id, then registered_country_geoname_id
|
||||||
|
location_data = @locations_cache[geoname_id] || @locations_cache[registered_country_geoname_id] || {}
|
||||||
|
|
||||||
|
additional_data = {
|
||||||
|
geoname_id: geoname_id,
|
||||||
|
registered_country_geoname_id: registered_country_geoname_id,
|
||||||
|
represented_country_geoname_id: row[:represented_country_geoname_id],
|
||||||
|
continent_code: location_data[:continent_code],
|
||||||
|
continent_name: location_data[:continent_name],
|
||||||
|
country_name: location_data[:country_name],
|
||||||
|
is_in_european_union: location_data[:is_in_european_union],
|
||||||
|
is_satellite_provider: is_satellite_provider,
|
||||||
|
is_anycast: is_anycast
|
||||||
|
}.compact
|
||||||
|
|
||||||
|
NetworkRange.upsert(
|
||||||
|
{
|
||||||
|
network: network,
|
||||||
|
country: location_data[:country_iso_code],
|
||||||
|
is_proxy: is_anonymous_proxy,
|
||||||
|
source: 'geolite_country',
|
||||||
|
additional_data: additional_data,
|
||||||
|
updated_at: Time.current
|
||||||
|
},
|
||||||
|
unique_by: :index_network_ranges_on_network_unique
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_progress_if_needed
|
||||||
|
if (@processed_records + @failed_records) % 100 == 0
|
||||||
|
@data_import.update_progress(
|
||||||
|
processed: @processed_records,
|
||||||
|
failed: @failed_records,
|
||||||
|
total_records: @total_records,
|
||||||
|
stats: {
|
||||||
|
total_records: @total_records,
|
||||||
|
current_file: File.basename(@file_path),
|
||||||
|
locations_loaded: @locations_cache.size,
|
||||||
|
recent_errors: @errors.last(5)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_locations_data
|
||||||
|
locations_files = find_locations_files
|
||||||
|
|
||||||
|
locations_files.each do |locations_file|
|
||||||
|
CSV.foreach(locations_file, headers: true, header_converters: :symbol, encoding: 'UTF-8') do |row|
|
||||||
|
geoname_id = row[:geoname_id]
|
||||||
|
next unless geoname_id
|
||||||
|
|
||||||
|
@locations_cache[geoname_id] = {
|
||||||
|
country_iso_code: row[:country_iso_code],
|
||||||
|
country_name: row[:country_name],
|
||||||
|
continent_code: row[:continent_code],
|
||||||
|
continent_name: row[:continent_name],
|
||||||
|
is_in_european_union: row[:is_in_european_union]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_locations_files
|
||||||
|
if @file_path.end_with?('.zip')
|
||||||
|
base_dir = File.dirname(@file_path)
|
||||||
|
base_name = File.basename(@file_path, '.zip')
|
||||||
|
|
||||||
|
# Look for English locations file first, then any locations file
|
||||||
|
[
|
||||||
|
File.join(base_dir, "#{base_name}-Locations-en.csv"),
|
||||||
|
Dir[File.join(base_dir, "#{base_name}-Locations-*.csv")].first
|
||||||
|
].compact.select { |file| File.exist?(file) }
|
||||||
|
else
|
||||||
|
base_dir = File.dirname(@file_path)
|
||||||
|
Dir[File.join(base_dir, "*Locations*.csv")].select { |file| File.exist?(file) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
159
app/services/network_range_generator.rb
Normal file
159
app/services/network_range_generator.rb
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Service for automatically creating network ranges for unmatched IPs
|
||||||
|
class NetworkRangeGenerator
|
||||||
|
include ActiveModel::Model
|
||||||
|
include ActiveModel::Attributes
|
||||||
|
|
||||||
|
# Minimum network sizes for different IP types
|
||||||
|
IPV4_MIN_SIZE = 24 # /24 = 256 IPs
|
||||||
|
IPV6_MIN_SIZE = 64 # /64 = 2^64 IPs (standard IPv6 allocation)
|
||||||
|
|
||||||
|
# Special network ranges to avoid
|
||||||
|
RESERVED_RANGES = [
|
||||||
|
IPAddr.new('10.0.0.0/8'), # Private
|
||||||
|
IPAddr.new('172.16.0.0/12'), # Private
|
||||||
|
IPAddr.new('192.168.0.0/16'), # Private
|
||||||
|
IPAddr.new('127.0.0.0/8'), # Loopback
|
||||||
|
IPAddr.new('169.254.0.0/16'), # Link-local
|
||||||
|
IPAddr.new('224.0.0.0/4'), # Multicast
|
||||||
|
IPAddr.new('240.0.0.0/4'), # Reserved
|
||||||
|
IPAddr.new('::1/128'), # IPv6 loopback
|
||||||
|
IPAddr.new('fc00::/7'), # IPv6 private
|
||||||
|
IPAddr.new('fe80::/10'), # IPv6 link-local
|
||||||
|
IPAddr.new('ff00::/8') # IPv6 multicast
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
# Special network ranges to avoid
|
||||||
|
RESERVED_RANGES = [
|
||||||
|
IPAddr.new('10.0.0.0/8'), # Private
|
||||||
|
IPAddr.new('172.16.0.0/12'), # Private
|
||||||
|
IPAddr.new('192.168.0.0/16'), # Private
|
||||||
|
IPAddr.new('127.0.0.0/8'), # Loopback
|
||||||
|
IPAddr.new('169.254.0.0/16'), # Link-local
|
||||||
|
IPAddr.new('224.0.0.0/4'), # Multicast
|
||||||
|
IPAddr.new('240.0.0.0/4'), # Reserved
|
||||||
|
IPAddr.new('::1/128'), # IPv6 loopback
|
||||||
|
IPAddr.new('fc00::/7'), # IPv6 private
|
||||||
|
IPAddr.new('fe80::/10'), # IPv6 link-local
|
||||||
|
IPAddr.new('ff00::/8') # IPv6 multicast
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Find or create a network range for the given IP address
|
||||||
|
def find_or_create_for_ip(ip_address, user: nil)
|
||||||
|
ip_str = ip_address.to_s
|
||||||
|
ip_obj = ip_address.is_a?(IPAddr) ? ip_address : IPAddr.new(ip_str)
|
||||||
|
|
||||||
|
# Check if IP already matches existing ranges
|
||||||
|
existing_range = NetworkRange.contains_ip(ip_str).first
|
||||||
|
if existing_range
|
||||||
|
# If we have an existing range and it's a /32 (single IP),
|
||||||
|
# create a larger network range instead for better analytics
|
||||||
|
if existing_range.masklen == 32
|
||||||
|
# Don't overwrite manually created or imported ranges
|
||||||
|
unless %w[manual user_created api_imported].include?(existing_range.source)
|
||||||
|
return create_appropriate_network(ip_obj, user: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return existing_range
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create the appropriate network range for this IP
|
||||||
|
create_appropriate_network(ip_obj, user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the appropriate minimum network size for an IP
|
||||||
|
def minimum_network_size(ip_address)
|
||||||
|
return IPV6_MIN_SIZE if ip_address.ipv6?
|
||||||
|
|
||||||
|
# For IPv4, use larger networks for known datacenter/ranges
|
||||||
|
if datacenter_ip?(ip_address)
|
||||||
|
20 # /20 = 4096 IPs for large providers
|
||||||
|
else
|
||||||
|
IPV4_MIN_SIZE # /24 = 256 IPs for general use
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if IP is in a datacenter range
|
||||||
|
def datacenter_ip?(ip_address)
|
||||||
|
# Known major cloud provider ranges
|
||||||
|
cloud_ranges = [
|
||||||
|
IPAddr.new('3.0.0.0/8'), # AWS
|
||||||
|
IPAddr.new('52.0.0.0/8'), # AWS
|
||||||
|
IPAddr.new('54.0.0.0/8'), # AWS
|
||||||
|
IPAddr.new('13.0.0.0/8'), # AWS
|
||||||
|
IPAddr.new('104.16.0.0/12'), # Cloudflare
|
||||||
|
IPAddr.new('172.64.0.0/13'), # Cloudflare
|
||||||
|
IPAddr.new('104.24.0.0/14'), # Cloudflare
|
||||||
|
IPAddr.new('172.68.0.0/14'), # Cloudflare
|
||||||
|
IPAddr.new('108.170.0.0/16'), # Google
|
||||||
|
IPAddr.new('173.194.0.0/16'), # Google
|
||||||
|
IPAddr.new('209.85.0.0/16'), # Google
|
||||||
|
IPAddr.new('157.240.0.0/16'), # Facebook/Meta
|
||||||
|
IPAddr.new('31.13.0.0/16'), # Facebook/Meta
|
||||||
|
IPAddr.new('69.63.0.0/16'), # Facebook/Meta
|
||||||
|
IPAddr.new('173.252.0.0/16'), # Facebook/Meta
|
||||||
|
IPAddr.new('20.0.0.0/8'), # Microsoft Azure
|
||||||
|
IPAddr.new('40.64.0.0/10'), # Microsoft Azure
|
||||||
|
IPAddr.new('40.96.0.0/11'), # Microsoft Azure
|
||||||
|
IPAddr.new('40.112.0.0/12'), # Microsoft Azure
|
||||||
|
IPAddr.new('40.123.0.0/16'), # Microsoft Azure
|
||||||
|
IPAddr.new('40.124.0.0/14'), # Microsoft Azure
|
||||||
|
IPAddr.new('40.126.0.0/15'), # Microsoft Azure
|
||||||
|
]
|
||||||
|
|
||||||
|
cloud_ranges.any? { |range| range.include?(ip_address) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Create the appropriate network range containing the IP
|
||||||
|
def create_appropriate_network(ip_address, user: nil)
|
||||||
|
prefix_length = minimum_network_size(ip_address)
|
||||||
|
|
||||||
|
# Create the network range with the IP at the center if possible
|
||||||
|
network_cidr = create_network_with_ip(ip_address, prefix_length)
|
||||||
|
|
||||||
|
# Check if network already exists
|
||||||
|
existing = NetworkRange.find_by(network: network_cidr)
|
||||||
|
return existing if existing
|
||||||
|
|
||||||
|
# Create new network range
|
||||||
|
NetworkRange.create!(
|
||||||
|
network: network_cidr,
|
||||||
|
source: 'auto_generated',
|
||||||
|
creation_reason: "auto-generated for unmatched IP traffic",
|
||||||
|
user: user,
|
||||||
|
company: nil, # Will be filled by enrichment job
|
||||||
|
asn: nil,
|
||||||
|
country: nil,
|
||||||
|
is_datacenter: datacenter_ip?(ip_address),
|
||||||
|
is_vpn: false,
|
||||||
|
is_proxy: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a network CIDR that contains the given IP with specified prefix length
|
||||||
|
def create_network_with_ip(ip_address, prefix_length)
|
||||||
|
# Convert IP to integer and apply mask
|
||||||
|
ip_int = ip_address.to_i
|
||||||
|
|
||||||
|
if ip_address.ipv6?
|
||||||
|
# For IPv6, mask to prefix length
|
||||||
|
mask = (2**128 - 1) ^ ((2**(128 - prefix_length)) - 1)
|
||||||
|
network_int = ip_int & mask
|
||||||
|
result = IPAddr.new(network_int, Socket::AF_INET6).mask(prefix_length)
|
||||||
|
else
|
||||||
|
# For IPv4, mask to prefix length
|
||||||
|
mask = (2**32 - 1) ^ ((2**(32 - prefix_length)) - 1)
|
||||||
|
network_int = ip_int & mask
|
||||||
|
result = IPAddr.new(network_int, Socket::AF_INET).mask(prefix_length)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return the CIDR notation
|
||||||
|
result.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
177
app/services/waf_policy_matcher.rb
Normal file
177
app/services/waf_policy_matcher.rb
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# WafPolicyMatcher - Service to match NetworkRanges against active WafPolicies
|
||||||
|
#
|
||||||
|
# This service provides efficient matching of network ranges against firewall policies
|
||||||
|
# and can generate rules when matches are found.
|
||||||
|
class WafPolicyMatcher
|
||||||
|
include ActiveModel::Model
|
||||||
|
include ActiveModel::Attributes
|
||||||
|
|
||||||
|
attr_accessor :network_range
|
||||||
|
attr_reader :matching_policies, :generated_rules
|
||||||
|
|
||||||
|
def initialize(network_range:)
|
||||||
|
@network_range = network_range
|
||||||
|
@matching_policies = []
|
||||||
|
@generated_rules = []
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find all active policies that match the given network range
|
||||||
|
def find_matching_policies
|
||||||
|
return [] unless network_range.present?
|
||||||
|
|
||||||
|
@matching_policies = active_policies.select do |policy|
|
||||||
|
policy.matches_network_range?(network_range)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sort by priority: country > asn > company > network_type, then by creation date
|
||||||
|
@matching_policies.sort_by do |policy|
|
||||||
|
priority_score = case policy.policy_type
|
||||||
|
when 'country'
|
||||||
|
1
|
||||||
|
when 'asn'
|
||||||
|
2
|
||||||
|
when 'company'
|
||||||
|
3
|
||||||
|
when 'network_type'
|
||||||
|
4
|
||||||
|
else
|
||||||
|
99
|
||||||
|
end
|
||||||
|
|
||||||
|
[priority_score, policy.created_at]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate rules from matching policies
|
||||||
|
def generate_rules
|
||||||
|
return [] if matching_policies.empty?
|
||||||
|
|
||||||
|
@generated_rules = matching_policies.map do |policy|
|
||||||
|
# Check if rule already exists for this network range and policy
|
||||||
|
existing_rule = Rule.find_by(
|
||||||
|
network_range: network_range,
|
||||||
|
waf_policy: policy,
|
||||||
|
enabled: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_rule
|
||||||
|
Rails.logger.debug "Rule already exists for network_range #{network_range.cidr} and policy #{policy.name}"
|
||||||
|
existing_rule
|
||||||
|
else
|
||||||
|
rule = policy.create_rule_for_network_range(network_range)
|
||||||
|
if rule
|
||||||
|
Rails.logger.info "Generated rule for network_range #{network_range.cidr} from policy #{policy.name}"
|
||||||
|
end
|
||||||
|
rule
|
||||||
|
end
|
||||||
|
end.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find and generate rules in one step
|
||||||
|
def match_and_generate_rules
|
||||||
|
find_matching_policies
|
||||||
|
generate_rules
|
||||||
|
end
|
||||||
|
|
||||||
|
# Class methods for batch processing
|
||||||
|
def self.process_network_range(network_range)
|
||||||
|
matcher = new(network_range: network_range)
|
||||||
|
matcher.match_and_generate_rules
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.batch_process_network_ranges(network_ranges)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
network_ranges.each do |network_range|
|
||||||
|
matcher = new(network_range: network_range)
|
||||||
|
result = matcher.match_and_generate_rules
|
||||||
|
results << {
|
||||||
|
network_range: network_range,
|
||||||
|
matching_policies: matcher.matching_policies,
|
||||||
|
generated_rules: matcher.generated_rules
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
results
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process network ranges that need policy evaluation
|
||||||
|
def self.process_ranges_without_policy_rules(limit: 100)
|
||||||
|
# Find network ranges that don't have policy-generated rules
|
||||||
|
# but have intelligence data that could match policies
|
||||||
|
ranges_needing_evaluation = NetworkRange
|
||||||
|
.left_joins(:rules)
|
||||||
|
.where("rules.id IS NULL OR rules.waf_policy_id IS NULL")
|
||||||
|
.where("(country IS NOT NULL OR asn IS NOT NULL OR company IS NOT NULL OR is_datacenter = true OR is_proxy = true OR is_vpn = true)")
|
||||||
|
.limit(limit)
|
||||||
|
.includes(:rules)
|
||||||
|
|
||||||
|
batch_process_network_ranges(ranges_needing_evaluation)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Re-evaluate all network ranges for policy changes
|
||||||
|
def self.reprocess_all_for_policy(waf_policy)
|
||||||
|
# Find all network ranges that could potentially match this policy
|
||||||
|
potential_ranges = case waf_policy.policy_type
|
||||||
|
when 'country'
|
||||||
|
NetworkRange.where(country: waf_policy.targets)
|
||||||
|
when 'asn'
|
||||||
|
NetworkRange.where(asn: waf_policy.targets)
|
||||||
|
when 'network_type'
|
||||||
|
NetworkRange.where(
|
||||||
|
"is_datacenter = ? OR is_proxy = ? OR is_vpn = ?",
|
||||||
|
waf_policy.targets.include?('datacenter'),
|
||||||
|
waf_policy.targets.include?('proxy'),
|
||||||
|
waf_policy.targets.include?('vpn')
|
||||||
|
)
|
||||||
|
when 'company'
|
||||||
|
# For company matching, we need to do text matching
|
||||||
|
NetworkRange.where("company ILIKE ANY (array[?])",
|
||||||
|
waf_policy.targets.map { |c| "%#{c}%" })
|
||||||
|
else
|
||||||
|
NetworkRange.none
|
||||||
|
end
|
||||||
|
|
||||||
|
results = []
|
||||||
|
potential_ranges.find_each do |network_range|
|
||||||
|
matcher = new(network_range: network_range)
|
||||||
|
if waf_policy.matches_network_range?(network_range)
|
||||||
|
rule = waf_policy.create_rule_for_network_range(network_range)
|
||||||
|
results << { network_range: network_range, generated_rule: rule } if rule
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
results
|
||||||
|
end
|
||||||
|
|
||||||
|
# Statistics and reporting
|
||||||
|
def self.matching_policies_for_network_range(network_range)
|
||||||
|
matcher = new(network_range: network_range)
|
||||||
|
matcher.find_matching_policies
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.policy_effectiveness_stats(waf_policy, days: 30)
|
||||||
|
cutoff_date = days.days.ago
|
||||||
|
|
||||||
|
rules = waf_policy.generated_rules.where('created_at > ?', cutoff_date)
|
||||||
|
|
||||||
|
{
|
||||||
|
policy_name: waf_policy.name,
|
||||||
|
policy_type: waf_policy.policy_type,
|
||||||
|
action: waf_policy.action,
|
||||||
|
rules_generated: rules.count,
|
||||||
|
active_rules: rules.active.count,
|
||||||
|
networks_protected: rules.joins(:network_range).count('distinct network_ranges.id'),
|
||||||
|
period_days: days,
|
||||||
|
generation_rate: rules.count.to_f / days
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def active_policies
|
||||||
|
@active_policies ||= WafPolicy.active
|
||||||
|
end
|
||||||
|
end
|
||||||
80
app/views/data_imports/_progress_card.html.erb
Normal file
80
app/views/data_imports/_progress_card.html.erb
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<div class="bg-white shadow-sm rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Import Progress</h2>
|
||||||
|
<%# Reuse the status_badge helper - need to define it here since it's a partial %>
|
||||||
|
<% def status_badge(status) %>
|
||||||
|
<% case status %>
|
||||||
|
<% when 'pending' %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% when 'processing' %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% when 'completed' %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% when 'failed' %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<%= status_badge(@data_import.status) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-600 mb-1">
|
||||||
|
<span><%= number_with_delimiter(@data_import.processed_records) %> of <%= number_with_delimiter(@data_import.total_records) %> records</span>
|
||||||
|
<span><%= @data_import.progress_percentage %>%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: <%= @data_import.progress_percentage %>%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900">
|
||||||
|
<%= number_with_delimiter(@data_import.total_records) %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">Total Records</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-semibold text-green-900">
|
||||||
|
<%= number_with_delimiter(@data_import.processed_records) %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-green-600">Processed</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-red-50 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-semibold text-red-900">
|
||||||
|
<%= number_with_delimiter(@data_import.failed_records) %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-red-600">Failed</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-semibold text-blue-900">
|
||||||
|
<%= number_with_delimiter(@data_import.records_per_second) %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-blue-600">Records/Sec</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Auto-refresh logic for completed/failed imports %>
|
||||||
|
<% if @data_import.completed? || @data_import.failed? %>
|
||||||
|
<script>
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
</script>
|
||||||
|
<% end %>
|
||||||
273
app/views/data_imports/index.html.erb
Normal file
273
app/views/data_imports/index.html.erb
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
<%# Helper methods %>
|
||||||
|
<% def status_badge_class(status) %>
|
||||||
|
<% case status %>
|
||||||
|
<% when 'pending' %>
|
||||||
|
bg-gray-100 text-gray-800
|
||||||
|
<% when 'processing' %>
|
||||||
|
bg-blue-100 text-blue-800
|
||||||
|
<% when 'completed' %>
|
||||||
|
bg-green-100 text-green-800
|
||||||
|
<% when 'failed' %>
|
||||||
|
bg-red-100 text-red-800
|
||||||
|
<% else %>
|
||||||
|
bg-gray-100 text-gray-800
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white shadow-sm rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">GeoLite2 Data Imports</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Manage and monitor your GeoLite2 database imports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<%= link_to "New Import", new_data_import_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white shadow-sm rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<%= form_with(url: data_imports_path, method: :get, local: true) do |f| %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<%= f.label :import_type, "Import Type", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||||
|
<%= f.select :import_type,
|
||||||
|
options_for_select([['All Types', ''], ['ASN', 'asn'], ['Country', 'country']], params[:import_type]),
|
||||||
|
{ }, { class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= f.label :status, "Status", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||||
|
<%= f.select :status,
|
||||||
|
options_for_select([['All Statuses', ''], ['Pending', 'pending'], ['Processing', 'processing'], ['Completed', 'completed'], ['Failed', 'failed']], params[:status]),
|
||||||
|
{ }, { class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= f.label :filename, "Filename", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||||
|
<%= f.text_field :filename, value: params[:filename], class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Search filename..." %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<%= f.submit "Filter", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium 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>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-white shadow-sm rounded-lg p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900"><%= DataImport.count %></div>
|
||||||
|
<div class="text-sm text-gray-600">Total Imports</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm rounded-lg p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<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="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900"><%= DataImport.completed.count %></div>
|
||||||
|
<div class="text-sm text-gray-600">Completed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm rounded-lg p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900"><%= DataImport.processing.count %></div>
|
||||||
|
<div class="text-sm text-gray-600">Processing</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm rounded-lg p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<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="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900"><%= DataImport.failed.count %></div>
|
||||||
|
<div class="text-sm text-gray-600">Failed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Imports Table -->
|
||||||
|
<div class="bg-white shadow-sm rounded-lg">
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Filename
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Type
|
||||||
|
</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">
|
||||||
|
Progress
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Duration
|
||||||
|
</th>
|
||||||
|
<th class="relative px-6 py-3">
|
||||||
|
<span class="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<% if @data_imports.any? %>
|
||||||
|
<% @data_imports.each do |data_import| %>
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<%= link_to data_import, class: "flex items-center text-blue-600 hover:text-blue-900 hover:underline" do %>
|
||||||
|
<svg class="w-4 h-4 text-gray-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
<%= truncate(data_import.filename, length: 40) %>
|
||||||
|
<% end %>
|
||||||
|
</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 <%= data_import.import_type == 'asn' ? 'bg-purple-100 text-purple-800' : 'bg-indigo-100 text-indigo-800' %>">
|
||||||
|
<%= data_import.import_type.upcase %>
|
||||||
|
</span>
|
||||||
|
</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 <%= status_badge_class(data_import.status) %>">
|
||||||
|
<%= data_import.status.capitalize %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<%= link_to data_import, class: "block hover:bg-gray-50 -mx-2 px-2 py-1 rounded" do %>
|
||||||
|
<% if data_import.processing? || data_import.total_records > 0 %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-1 mr-2">
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div class="bg-blue-600 h-2 rounded-full" style="width: <%= data_import.progress_percentage %>%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-600">
|
||||||
|
<% if data_import.processed_records > 0 %>
|
||||||
|
<% if data_import.total_records > 0 && data_import.processed_records >= data_import.total_records %>
|
||||||
|
<%= number_with_delimiter(data_import.processed_records) %> total
|
||||||
|
<% else %>
|
||||||
|
<%= number_with_delimiter(data_import.processed_records) %> imported
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
Initializing...
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">Not started</span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<%= data_import.created_at.strftime('%Y-%m-%d %H:%M') %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<% if data_import.duration > 0 %>
|
||||||
|
<%= distance_of_time_in_words(data_import.duration) %>
|
||||||
|
<% else %>
|
||||||
|
-
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<% unless data_import.processing? %>
|
||||||
|
<%= link_to "Delete", data_import, method: :delete,
|
||||||
|
data: {
|
||||||
|
confirm: "Are you sure you want to delete this import?"
|
||||||
|
},
|
||||||
|
class: "text-red-600 hover:text-red-900" %>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">Processing...</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-12 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No imports found</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
<% if params[:import_type].present? || params[:status].present? || params[:filename].present? %>
|
||||||
|
Try adjusting your search filters or
|
||||||
|
<% else %>
|
||||||
|
Get started by uploading your first
|
||||||
|
<% end %>
|
||||||
|
<%= link_to "GeoLite2 import", new_data_import_path, class: "text-blue-600 hover:text-blue-500" %>.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<% if @pagy.pages > 1 %>
|
||||||
|
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||||
|
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
Showing
|
||||||
|
<span class="font-medium"><%= @pagy.from %></span>
|
||||||
|
to
|
||||||
|
<span class="font-medium"><%= @pagy.to %></span>
|
||||||
|
of
|
||||||
|
<span class="font-medium"><%= @pagy.count %></span>
|
||||||
|
results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= pagy_nav_tailwind(@pagy) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
162
app/views/data_imports/new.html.erb
Normal file
162
app/views/data_imports/new.html.erb
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="bg-white shadow-sm rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Import GeoLite2 Data</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Upload GeoLite2-ASN-CSV or GeoLite2-Country-CSV files to import network range data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<%= form_with(model: @data_import, local: true, class: "space-y-6") do |form| %>
|
||||||
|
<% if @data_import.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(@data_import.errors.count, "error") %> with your submission:
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<% @data_import.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- File Upload Section -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= form.label :file, "Select File", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-gray-400 transition-colors">
|
||||||
|
<div class="space-y-1 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||||
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex text-sm text-gray-600">
|
||||||
|
<label for="data_import_file" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<%= form.file_field :file, id: "data_import_file", class: "sr-only", accept: ".csv,.zip", required: true %>
|
||||||
|
</label>
|
||||||
|
<p class="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
CSV or ZIP files up to 500MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Type Detection -->
|
||||||
|
<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">Automatic Detection</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<p>The system will automatically detect whether your file contains ASN or Country data based on:</p>
|
||||||
|
<ul class="mt-1 list-disc list-inside">
|
||||||
|
<li>Filename (containing "ASN" or "Country")</li>
|
||||||
|
<li>Column headers in the CSV file</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Supported Formats -->
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||||
|
<h3 class="text-sm font-medium text-gray-800 mb-3">Supported File Formats</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700">GeoLite2-ASN-CSV</h4>
|
||||||
|
<p class="text-xs text-gray-600">Contains network ranges with ASN and organization information</p>
|
||||||
|
<p class="text-xs text-gray-500">Expected files: GeoLite2-ASN-Blocks-IPv4.csv, GeoLite2-ASN-Blocks-IPv6.csv</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700">GeoLite2-Country-CSV</h4>
|
||||||
|
<p class="text-xs text-gray-600">Contains network ranges with country geolocation data</p>
|
||||||
|
<p class="text-xs text-gray-500">Expected files: GeoLite2-Country-Blocks-IPv4.csv, GeoLite2-Country-Blocks-IPv6.csv, GeoLite2-Country-Locations-*.csv</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="pt-4">
|
||||||
|
<%= form.submit "Start Import", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 cursor-pointer transition-colors" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const fileInput = document.getElementById('data_import_file');
|
||||||
|
const dropZone = fileInput.closest('.border-dashed');
|
||||||
|
|
||||||
|
// Handle drag and drop
|
||||||
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function preventDefaults(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, highlight, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, unhighlight, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function highlight(e) {
|
||||||
|
dropZone.classList.add('border-blue-400', 'bg-blue-50');
|
||||||
|
}
|
||||||
|
|
||||||
|
function unhighlight(e) {
|
||||||
|
dropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||||
|
}
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', handleDrop, false);
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
const files = dt.files;
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
fileInput.files = files;
|
||||||
|
updateFileName(files[0].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', function(e) {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
updateFileName(e.target.files[0].name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateFileName(fileName) {
|
||||||
|
const textElement = dropZone.querySelector('p.text-xs');
|
||||||
|
textElement.textContent = `Selected: ${fileName}`;
|
||||||
|
textElement.classList.add('text-green-600', 'font-medium');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
222
app/views/data_imports/show.html.erb
Normal file
222
app/views/data_imports/show.html.erb
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<%# Helper methods %>
|
||||||
|
<% def status_badge(status) %>
|
||||||
|
<% case status %>
|
||||||
|
<% when 'pending' %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% when 'processing' %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% when 'completed' %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% when 'failed' %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<%= status.capitalize %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white shadow-sm rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Import Details</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
<%= @data_import.filename %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<%= link_to "← Back to Imports", data_imports_path, class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 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" %>
|
||||||
|
<% unless @data_import.processing? %>
|
||||||
|
<%= link_to "Delete", @data_import, method: :delete,
|
||||||
|
data: {
|
||||||
|
confirm: "Are you sure you want to delete this import record?"
|
||||||
|
},
|
||||||
|
class: "inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Card -->
|
||||||
|
<div data-controller="data-import-progress"
|
||||||
|
data-data-import-progress-import-id-value="<%= @data_import.id %>"
|
||||||
|
class="bg-white shadow-sm rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Import Progress</h2>
|
||||||
|
<%= status_badge(@data_import.status) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-600 mb-1">
|
||||||
|
<span>
|
||||||
|
<% if @data_import.total_records > 0 && @data_import.processed_records >= @data_import.total_records %>
|
||||||
|
<%= number_with_delimiter(@data_import.processed_records) %> total records
|
||||||
|
<% elsif @data_import.total_records > 0 %>
|
||||||
|
<%= number_with_delimiter(@data_import.processed_records) %> records processed
|
||||||
|
<% else %>
|
||||||
|
Initializing...
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
<span><%= @data_import.progress_percentage %>%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div data-data-import-progress-target="progressBar"
|
||||||
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: <%= @data_import.progress_percentage %>%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900" data-data-import-progress-target="totalRecords">
|
||||||
|
<%= number_with_delimiter(@data_import.total_records) %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">Total Records</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-semibold text-green-900" data-data-import-progress-target="processedRecords">
|
||||||
|
<%= number_with_delimiter(@data_import.processed_records) %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-green-600">Processed</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-red-50 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-semibold text-red-900" data-data-import-progress-target="failedRecords">
|
||||||
|
<%= number_with_delimiter(@data_import.failed_records) %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-red-600">Failed</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-semibold text-blue-900" data-data-import-progress-target="recordsPerSecond">
|
||||||
|
<%= number_with_delimiter(@data_import.records_per_second) %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-blue-600">Records/Sec</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Details -->
|
||||||
|
<div class="bg-white shadow-sm rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Import Information</h2>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Import Type</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 capitalize"><%= @data_import.import_type %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Filename</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @data_import.filename %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Started</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @data_import.processing? && @data_import.started_at %>
|
||||||
|
<%= time_ago_in_words(@data_import.started_at) %> ago
|
||||||
|
(<%= @data_import.started_at.strftime('%Y-%m-%d %H:%M:%S') %>)
|
||||||
|
<% elsif @data_import.processing? %>
|
||||||
|
Initializing...
|
||||||
|
<% elsif @data_import.started_at %>
|
||||||
|
<%= time_ago_in_words(@data_import.started_at) %> ago
|
||||||
|
(<%= @data_import.started_at.strftime('%Y-%m-%d %H:%M:%S') %>)
|
||||||
|
<% else %>
|
||||||
|
Not started
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Duration</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @data_import.duration > 0 %>
|
||||||
|
<%= distance_of_time_in_words(@data_import.duration) %>
|
||||||
|
<% else %>
|
||||||
|
N/A
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Completed</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @data_import.completed? && @data_import.completed_at %>
|
||||||
|
<%= time_ago_in_words(@data_import.completed_at) %> ago
|
||||||
|
(<%= @data_import.completed_at.strftime('%Y-%m-%d %H:%M:%S') %>)
|
||||||
|
<% elsif @data_import.completed? %>
|
||||||
|
Just now
|
||||||
|
<% elsif @data_import.processing? %>
|
||||||
|
In progress...
|
||||||
|
<% else %>
|
||||||
|
Not completed
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Details (if any) -->
|
||||||
|
<% if @data_import.error_message.present? || @data_import.import_stats['errors']&.any? %>
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-red-200">
|
||||||
|
<h2 class="text-lg font-medium text-red-900">Error Details</h2>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<% if @data_import.error_message.present? %>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-sm font-medium text-red-800 mb-2">General Error</h3>
|
||||||
|
<p class="text-sm text-red-700"><%= @data_import.error_message %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @data_import.import_stats['errors']&.any? %>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-red-800 mb-2">Recent Errors (<%= @data_import.import_stats['errors'].size %>)</h3>
|
||||||
|
<div class="bg-white rounded border border-red-200 p-3 max-h-48 overflow-y-auto">
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<% @data_import.import_stats['errors'].each do |error| %>
|
||||||
|
<li class="text-xs text-red-700 font-mono"><%= error %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Additional Stats (if available) -->
|
||||||
|
<% if @data_import.import_stats&.any? && (@data_import.import_stats.except('errors', 'completed_at')).any? %>
|
||||||
|
<div class="bg-white shadow-sm rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Additional Statistics</h2>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
|
<% @data_import.import_stats.except('errors', 'completed_at').each do |key, value| %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500"><%= key.to_s.humanize %></dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= value.is_a?(Hash) ? value.inspect : value %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@
|
|||||||
|
|
||||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||||
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||||
|
|
||||||
|
<%# Tom Select CSS for enhanced multi-select %>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" data-turbo-track="reload">
|
||||||
|
|
||||||
<%= javascript_importmap_tags %>
|
<%= javascript_importmap_tags %>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -74,6 +78,8 @@
|
|||||||
class: nav_link_class(network_ranges_path) %>
|
class: nav_link_class(network_ranges_path) %>
|
||||||
|
|
||||||
<% if user_signed_in? && current_user_admin? %>
|
<% if user_signed_in? && current_user_admin? %>
|
||||||
|
<%= link_to "📊 Data Imports", data_imports_path,
|
||||||
|
class: nav_link_class(data_imports_path) %>
|
||||||
<%= link_to "🔗 DSNs", dsns_path,
|
<%= link_to "🔗 DSNs", dsns_path,
|
||||||
class: nav_link_class(dsns_path) %>
|
class: nav_link_class(dsns_path) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -165,6 +171,8 @@
|
|||||||
class: mobile_nav_link_class(network_ranges_path) %>
|
class: mobile_nav_link_class(network_ranges_path) %>
|
||||||
|
|
||||||
<% if user_signed_in? && current_user_admin? %>
|
<% if user_signed_in? && current_user_admin? %>
|
||||||
|
<%= link_to "📊 Data Imports", data_imports_path,
|
||||||
|
class: mobile_nav_link_class(data_imports_path) %>
|
||||||
<%= link_to "🔗 DSNs", dsns_path,
|
<%= link_to "🔗 DSNs", dsns_path,
|
||||||
class: mobile_nav_link_class(dsns_path) %>
|
class: mobile_nav_link_class(dsns_path) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
240
app/views/waf_policies/edit.html.erb
Normal file
240
app/views/waf_policies/edit.html.erb
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<% content_for :title, "Edit WAF Policy" %>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Edit WAF Policy</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Modify the firewall policy settings</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "← Back to Policy", waf_policy_path(@waf_policy),
|
||||||
|
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>
|
||||||
|
|
||||||
|
<%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %>
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">📋 Basic Information</h3>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :name,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., Block Brazil" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 3,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "Explain why this policy is needed..." %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :action,
|
||||||
|
options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.action),
|
||||||
|
{ prompt: "Select action" },
|
||||||
|
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
id: "action-select" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :enabled, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %>
|
||||||
|
<%= form.label :enabled, "Enable this policy", class: "ml-2 text-sm text-gray-700" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiration -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :expires_at, "Expires At (optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.datetime_local_field :expires_at,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leave blank for permanent policy</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Targets Configuration -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">🎯 Targets Configuration</h3>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
<strong>Policy Type:</strong> <%= @waf_policy.policy_type.humanize %>
|
||||||
|
<% unless @waf_policy.new_record? %>
|
||||||
|
<span class="text-xs text-gray-500">(Cannot change policy type after creation)</span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% if @waf_policy.new_record? %>
|
||||||
|
<!-- Policy Type (only for new records) -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :policy_type, "Policy Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :policy_type,
|
||||||
|
options_for_select(@policy_types.map { |type| [type.humanize, type] }, @waf_policy.policy_type),
|
||||||
|
{ prompt: "Select policy type" },
|
||||||
|
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
id: "policy-type-select", disabled: !@waf_policy.new_record? } %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<!-- Display policy type for existing records -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :policy_type, "Policy Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="mt-1">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= @waf_policy.policy_type.humanize %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<%= form.hidden_field :policy_type %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Country Policy Targets -->
|
||||||
|
<% if @waf_policy.country_policy? %>
|
||||||
|
<div id="country-targets">
|
||||||
|
<%= form.label :targets, "Countries", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||||
|
<div data-controller="country-selector"
|
||||||
|
data-country-selector-options-value="<%= CountryHelper.all_for_select.to_json %>"
|
||||||
|
data-country-selector-placeholder-value="Search and select countries...">
|
||||||
|
<%= select_tag "waf_policy[targets][]",
|
||||||
|
options_for_select(@waf_policy.targets.map { |code| [CountryHelper.display_with_flag(code), code] }, @waf_policy.targets),
|
||||||
|
{
|
||||||
|
multiple: true,
|
||||||
|
class: "hidden",
|
||||||
|
data: { "country-selector-target": "select" }
|
||||||
|
} %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- ASN Policy Targets -->
|
||||||
|
<% if @waf_policy.asn_policy? %>
|
||||||
|
<div id="asn-targets">
|
||||||
|
<%= form.label :targets, "ASN Numbers", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_field_tag "waf_policy[targets][]", @waf_policy.targets.join(', '),
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., 12345, 67890" %>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Enter ASNs separated by commas</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Company Policy Targets -->
|
||||||
|
<% if @waf_policy.company_policy? %>
|
||||||
|
<div id="company-targets">
|
||||||
|
<%= form.label :targets, "Companies", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_field_tag "waf_policy[targets][]", @waf_policy.targets.join(', '),
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., AWS, Digital Ocean, Google Cloud" %>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Enter company names separated by commas</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Network Type Targets -->
|
||||||
|
<% if @waf_policy.network_type_policy? %>
|
||||||
|
<div id="network-type-targets">
|
||||||
|
<%= form.label :targets, "Network Types", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= check_box_tag "waf_policy[targets][]", "datacenter", @waf_policy.targets.include?("datacenter"), class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
|
||||||
|
<span class="text-sm text-gray-700">Datacenter IPs</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= check_box_tag "waf_policy[targets][]", "proxy", @waf_policy.targets.include?("proxy"), class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
|
||||||
|
<span class="text-sm text-gray-700">Proxy/VPN IPs</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= check_box_tag "waf_policy[targets][]", "standard", @waf_policy.targets.include?("standard"), class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
|
||||||
|
<span class="text-sm text-gray-700">Standard ISPs</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Configuration -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">⚙️ Additional Configuration</h3>
|
||||||
|
|
||||||
|
<!-- Redirect Settings (for redirect action) -->
|
||||||
|
<div id="redirect-config" class="space-y-3 <%= 'hidden' unless @waf_policy.redirect_action? %>">
|
||||||
|
<div>
|
||||||
|
<%= label_tag "additional_data[redirect_url]", "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_field_tag "additional_data[redirect_url]", @waf_policy.additional_data&.dig('redirect_url'),
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "https://example.com/compliance" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= label_tag "additional_data[redirect_status]", "HTTP Status", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= select_tag "additional_data[redirect_status]",
|
||||||
|
options_for_select([["301 Moved Permanently", 301], ["302 Found", 302], ["307 Temporary Redirect", 307]], @waf_policy.additional_data&.dig('redirect_status')),
|
||||||
|
{ include_blank: true, class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Challenge Settings (for challenge action) -->
|
||||||
|
<div id="challenge-config" class="space-y-3 <%= 'hidden' unless @waf_policy.challenge_action? %>">
|
||||||
|
<div>
|
||||||
|
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= select_tag "additional_data[challenge_type]",
|
||||||
|
options_for_select([["CAPTCHA", "captcha"], ["JavaScript", "javascript"], ["Proof of Work", "proof_of_work"]], @waf_policy.additional_data&.dig('challenge_type')),
|
||||||
|
{ include_blank: true, class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= label_tag "additional_data[challenge_message]", "Challenge Message", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_area_tag "additional_data[challenge_message]", @waf_policy.additional_data&.dig('challenge_message'), rows: 2,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "Please verify you are human to continue..." %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<%= link_to "Cancel", waf_policy_path(@waf_policy),
|
||||||
|
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" %>
|
||||||
|
<%= form.submit "Update Policy",
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const actionSelect = document.getElementById('action-select');
|
||||||
|
const redirectConfig = document.getElementById('redirect-config');
|
||||||
|
const challengeConfig = document.getElementById('challenge-config');
|
||||||
|
|
||||||
|
function updateActionConfig() {
|
||||||
|
const selectedAction = actionSelect.value;
|
||||||
|
|
||||||
|
// Hide all config sections
|
||||||
|
redirectConfig.classList.add('hidden');
|
||||||
|
challengeConfig.classList.add('hidden');
|
||||||
|
|
||||||
|
// Show relevant config section
|
||||||
|
switch(selectedAction) {
|
||||||
|
case 'redirect':
|
||||||
|
redirectConfig.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'challenge':
|
||||||
|
challengeConfig.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener
|
||||||
|
actionSelect.addEventListener('change', updateActionConfig);
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateActionConfig();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -150,7 +150,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm text-gray-500">
|
<div class="mt-1 text-sm text-gray-500">
|
||||||
<%= policy.policy_type.humanize %> policy targeting
|
<%= policy.policy_type.humanize %> policy targeting
|
||||||
|
<% if policy.country_policy? && policy.targets.any? %>
|
||||||
<% if policy.targets.length > 3 %>
|
<% if policy.targets.length > 3 %>
|
||||||
|
<%= policy.targets.length %> countries
|
||||||
|
<% else %>
|
||||||
|
<%= policy.targets.map { |code| CountryHelper.display_with_flag(code) }.join(', ') %>
|
||||||
|
<% end %>
|
||||||
|
<% elsif policy.targets.length > 3 %>
|
||||||
<%= policy.targets.length %> items
|
<%= policy.targets.length %> items
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= policy.targets.join(', ') %>
|
<%= policy.targets.join(', ') %>
|
||||||
|
|||||||
240
app/views/waf_policies/new.html.erb
Normal file
240
app/views/waf_policies/new.html.erb
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<% content_for :title, "New WAF Policy" %>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">New WAF Policy</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Create a new firewall policy to automatically generate rules</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "← Back to Policies", waf_policies_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>
|
||||||
|
|
||||||
|
<%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %>
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">📋 Basic Information</h3>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :name,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., Block Brazil" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 3,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "Explain why this policy is needed..." %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Policy Type -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :policy_type, "Policy Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :policy_type,
|
||||||
|
options_for_select(@policy_types.map { |type| [type.humanize, type] }, @waf_policy.policy_type),
|
||||||
|
{ prompt: "Select policy type" },
|
||||||
|
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
id: "policy-type-select" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :action,
|
||||||
|
options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.action),
|
||||||
|
{ prompt: "Select action" },
|
||||||
|
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
id: "action-select" } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Targets Configuration -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">🎯 Targets Configuration</h3>
|
||||||
|
|
||||||
|
<!-- Country Policy Targets -->
|
||||||
|
<div id="country-targets" class="policy-targets hidden">
|
||||||
|
<%= form.label :targets, "Countries", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||||
|
<div data-controller="country-selector"
|
||||||
|
data-country-selector-options-value="<%= CountryHelper.all_for_select.to_json %>"
|
||||||
|
data-country-selector-placeholder-value="Search and select countries...">
|
||||||
|
<%= select_tag "waf_policy[targets][]",
|
||||||
|
options_for_select([]),
|
||||||
|
{
|
||||||
|
multiple: true,
|
||||||
|
class: "hidden",
|
||||||
|
data: { "country-selector-target": "select" }
|
||||||
|
} %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ASN Policy Targets -->
|
||||||
|
<div id="asn-targets" class="policy-targets hidden">
|
||||||
|
<%= form.label :targets, "ASN Numbers", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_field_tag "waf_policy[targets][]", nil,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., 12345, 67890" %>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Enter ASNs separated by commas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company Policy Targets -->
|
||||||
|
<div id="company-targets" class="policy-targets hidden">
|
||||||
|
<%= form.label :targets, "Companies", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_field_tag "waf_policy[targets][]", nil,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., AWS, Digital Ocean, Google Cloud" %>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Enter company names separated by commas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Type Targets -->
|
||||||
|
<div id="network-type-targets" class="policy-targets hidden">
|
||||||
|
<%= form.label :targets, "Network Types", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= check_box_tag "waf_policy[targets][]", "datacenter", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
|
||||||
|
<span class="text-sm text-gray-700">Datacenter IPs</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= check_box_tag "waf_policy[targets][]", "proxy", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
|
||||||
|
<span class="text-sm text-gray-700">Proxy/VPN IPs</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= check_box_tag "waf_policy[targets][]", "standard", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
|
||||||
|
<span class="text-sm text-gray-700">Standard ISPs</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Configuration -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">⚙️ Additional Configuration</h3>
|
||||||
|
|
||||||
|
<!-- Redirect Settings (for redirect action) -->
|
||||||
|
<div id="redirect-config" class="hidden space-y-3">
|
||||||
|
<div>
|
||||||
|
<%= label_tag "additional_data[redirect_url]", "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_field_tag "additional_data[redirect_url]", nil,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "https://example.com/compliance" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= label_tag "additional_data[redirect_status]", "HTTP Status", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= select_tag "additional_data[redirect_status]",
|
||||||
|
options_for_select([["301 Moved Permanently", 301], ["302 Found", 302], ["307 Temporary Redirect", 307]]),
|
||||||
|
{ include_blank: true, class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Challenge Settings (for challenge action) -->
|
||||||
|
<div id="challenge-config" class="hidden space-y-3">
|
||||||
|
<div>
|
||||||
|
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= select_tag "additional_data[challenge_type]",
|
||||||
|
options_for_select([["CAPTCHA", "captcha"], ["JavaScript", "javascript"], ["Proof of Work", "proof_of_work"]]),
|
||||||
|
{ include_blank: true, class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= label_tag "additional_data[challenge_message]", "Challenge Message", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_area_tag "additional_data[challenge_message]", nil, rows: 2,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "Please verify you are human to continue..." %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiration -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :expires_at, "Expires At (optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.datetime_local_field :expires_at,
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leave blank for permanent policy</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enabled -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :enabled, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %>
|
||||||
|
<%= form.label :enabled, "Enable this policy immediately", class: "ml-2 text-sm text-gray-700" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<%= link_to "Cancel", waf_policies_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" %>
|
||||||
|
<%= form.submit "Create Policy",
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const policyTypeSelect = document.getElementById('policy-type-select');
|
||||||
|
const actionSelect = document.getElementById('action-select');
|
||||||
|
const allTargets = document.querySelectorAll('.policy-targets');
|
||||||
|
const redirectConfig = document.getElementById('redirect-config');
|
||||||
|
const challengeConfig = document.getElementById('challenge-config');
|
||||||
|
|
||||||
|
function updateTargetsVisibility() {
|
||||||
|
const selectedType = policyTypeSelect.value;
|
||||||
|
|
||||||
|
// Hide all target sections
|
||||||
|
allTargets.forEach(target => target.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Show relevant target section
|
||||||
|
switch(selectedType) {
|
||||||
|
case 'country':
|
||||||
|
document.getElementById('country-targets').classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'asn':
|
||||||
|
document.getElementById('asn-targets').classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'company':
|
||||||
|
document.getElementById('company-targets').classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'network_type':
|
||||||
|
document.getElementById('network-type-targets').classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActionConfig() {
|
||||||
|
const selectedAction = actionSelect.value;
|
||||||
|
|
||||||
|
// Hide all config sections
|
||||||
|
redirectConfig.classList.add('hidden');
|
||||||
|
challengeConfig.classList.add('hidden');
|
||||||
|
|
||||||
|
// Show relevant config section
|
||||||
|
switch(selectedAction) {
|
||||||
|
case 'redirect':
|
||||||
|
redirectConfig.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'challenge':
|
||||||
|
challengeConfig.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
policyTypeSelect.addEventListener('change', updateTargetsVisibility);
|
||||||
|
actionSelect.addEventListener('change', updateActionConfig);
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateTargetsVisibility();
|
||||||
|
updateActionConfig();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
194
app/views/waf_policies/new_country.html.erb
Normal file
194
app/views/waf_policies/new_country.html.erb
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<% content_for :title, "Block Countries" %>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">🌍 Block Countries</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Create country-based firewall policies to block or redirect traffic from specific countries</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "← Back to Policies", waf_policies_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>
|
||||||
|
|
||||||
|
<%= form_with(url: create_country_waf_policies_path, method: :post, local: true, class: "space-y-6") do |form| %>
|
||||||
|
<!-- Popular Countries Quick Selection -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">🚨 Popular Countries for Blocking</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<% CountryHelper.popular_for_blocking.each do |country| %>
|
||||||
|
<label class="relative flex items-start p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<%= check_box_tag "countries[]", country[:code], false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<div class="font-medium text-gray-900">
|
||||||
|
<%= country[:display] %>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500">
|
||||||
|
<%= country[:reason] %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Regional Selection -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">🗺️ Select by Region</h3>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<% CountryHelper.by_region.each do |region_name, country_codes| %>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900 mb-2"><%= region_name %></h4>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<% CountryHelper.countries_for_region(region_name).each do |country| %>
|
||||||
|
<label class="flex items-center text-sm">
|
||||||
|
<%= check_box_tag "countries[]", country[:code], false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
|
||||||
|
<%= country[:display] %>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Policy Settings -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">⚙️ Policy Settings</h3>
|
||||||
|
|
||||||
|
<!-- Action Selection -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :action, "What should happen to traffic from selected countries?", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= radio_button_tag "action", "deny", true, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">
|
||||||
|
<span class="font-medium">🚫 Block (Deny)</span> - Show 403 Forbidden error
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= radio_button_tag "action", "challenge", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">
|
||||||
|
<span class="font-medium">🛡️ Challenge</span> - Present CAPTCHA challenge
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= radio_button_tag "action", "redirect", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">
|
||||||
|
<span class="font-medium">🔄 Redirect</span> - Redirect to compliance page
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<%= radio_button_tag "action", "allow", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">
|
||||||
|
<span class="font-medium">✅ Allow</span> - Explicitly allow traffic
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Redirect Settings (conditional) -->
|
||||||
|
<div id="redirect-settings" class="hidden space-y-3">
|
||||||
|
<%= form.label :redirect_url, "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_field_tag "additional_data[redirect_url]", nil,
|
||||||
|
placeholder: "https://example.com/compliance",
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %>
|
||||||
|
|
||||||
|
<%= form.label :redirect_status, "HTTP Status", class: "block text-sm font-medium text-gray-700 mt-2" %>
|
||||||
|
<%= select_tag "additional_data[redirect_status]",
|
||||||
|
options_for_select([["301 Moved Permanently", 301], ["302 Found", 302], ["307 Temporary Redirect", 307]], 302),
|
||||||
|
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, "Description (optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= text_area_tag "description", nil, rows: 3,
|
||||||
|
placeholder: "e.g., Block countries with high scanner activity",
|
||||||
|
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div id="policy-preview" class="border-t pt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">📋 Policy Preview</h4>
|
||||||
|
<div class="bg-gray-50 rounded-md p-3 text-sm text-gray-600">
|
||||||
|
<span id="preview-text">Select countries above to see policy preview...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<%= link_to "Cancel", waf_policies_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" %>
|
||||||
|
<%= submit_tag "Create Country Policy",
|
||||||
|
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Show/hide redirect settings based on action selection
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const actionRadios = document.querySelectorAll('input[name="action"]');
|
||||||
|
const redirectSettings = document.getElementById('redirect-settings');
|
||||||
|
const previewText = document.getElementById('preview-text');
|
||||||
|
|
||||||
|
function updateVisibility() {
|
||||||
|
const selectedAction = document.querySelector('input[name="action"]:checked')?.value;
|
||||||
|
|
||||||
|
if (selectedAction === 'redirect') {
|
||||||
|
redirectSettings.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
redirectSettings.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const selectedCountries = document.querySelectorAll('input[name="countries[]"]:checked');
|
||||||
|
const selectedAction = document.querySelector('input[name="action"]:checked')?.value || 'deny';
|
||||||
|
const actionText = {
|
||||||
|
'deny': '🚫 Block',
|
||||||
|
'challenge': '🛡️ Challenge',
|
||||||
|
'redirect': '🔄 Redirect',
|
||||||
|
'allow': '✅ Allow'
|
||||||
|
}[selectedAction];
|
||||||
|
|
||||||
|
if (selectedCountries.length > 0) {
|
||||||
|
const countryNames = Array.from(selectedCountries).map(cb => {
|
||||||
|
const label = cb.closest('label');
|
||||||
|
const countryName = label.querySelector('.font-medium')?.textContent || cb.value;
|
||||||
|
return countryName;
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
previewText.textContent = `${actionText} ${selectedCountries.length} countries: ${countryNames}`;
|
||||||
|
} else {
|
||||||
|
previewText.textContent = 'Select countries above to see policy preview...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
actionRadios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', updateVisibility);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="countries[]"]').forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', updatePreview);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateVisibility();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
270
app/views/waf_policies/show.html.erb
Normal file
270
app/views/waf_policies/show.html.erb
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<% content_for :title, @waf_policy.name %>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900"><%= @waf_policy.name %></h1>
|
||||||
|
<p class="mt-2 text-gray-600"><%= @waf_policy.description %></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "← Back to Policies", waf_policies_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 "Edit", edit_waf_policy_path(@waf_policy),
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Policy Details -->
|
||||||
|
<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">📋 Policy Details</h3>
|
||||||
|
</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">Policy Type</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 bg-blue-100 text-blue-800">
|
||||||
|
<%= @waf_policy.policy_type.humanize %>
|
||||||
|
</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">Action</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
|
||||||
|
<%= case @waf_policy.action
|
||||||
|
when 'deny' then 'bg-red-100 text-red-800'
|
||||||
|
when 'allow' then 'bg-green-100 text-green-800'
|
||||||
|
when 'redirect' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
when 'challenge' then 'bg-purple-100 text-purple-800'
|
||||||
|
end %>">
|
||||||
|
<%= @waf_policy.action.upcase %>
|
||||||
|
</span>
|
||||||
|
</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">Targets</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<% if @waf_policy.targets.any? %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% @waf_policy.targets.each do |target| %>
|
||||||
|
<% if @waf_policy.country_policy? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<%= CountryHelper.display_with_flag(target) %>
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<%= target %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">No targets configured</span>
|
||||||
|
<% end %>
|
||||||
|
</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">Status</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
|
<%= @waf_policy.active? ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' %>">
|
||||||
|
<%= @waf_policy.active? ? 'Active' : 'Inactive' %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<% if @waf_policy.active? %>
|
||||||
|
<%= link_to "Deactivate", deactivate_waf_policy_path(@waf_policy), method: :post,
|
||||||
|
data: { confirm: "Are you sure you want to deactivate this policy?" },
|
||||||
|
class: "text-sm text-red-600 hover:text-red-900" %>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to "Activate", activate_waf_policy_path(@waf_policy), method: :post,
|
||||||
|
class: "text-sm text-green-600 hover:text-green-900" %>
|
||||||
|
<% end %>
|
||||||
|
</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">Expires At</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<% if @waf_policy.expires_at.present? %>
|
||||||
|
<%= @waf_policy.expires_at.strftime("%B %d, %Y at %I:%M %p") %>
|
||||||
|
<% if @waf_policy.expired? %>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
Expired
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">Never expires</span>
|
||||||
|
<% end %>
|
||||||
|
</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 By</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<%= @waf_policy.user.email_address %>
|
||||||
|
</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">Created</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<%= @waf_policy.created_at.strftime("%B %d, %Y at %I:%M %p") %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Configuration -->
|
||||||
|
<% if @waf_policy.additional_data && @waf_policy.additional_data.any? %>
|
||||||
|
<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">Additional Config</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<% @waf_policy.additional_data.each do |key, value| %>
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="font-medium"><%= key.humanize %>:</span>
|
||||||
|
<span class="text-gray-600"><%= value %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<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">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Generated Rules</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= @waf_policy.generated_rules_count %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</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"><%= @waf_policy.active_rules_count %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Rules Last 7 Days</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= @waf_policy.effectiveness_stats[:rules_last_7_days] %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Efficiency Rate</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
|
<%= @waf_policy.active_rules_count.to_f / [@waf_policy.generated_rules_count, 1].max * 100 %>%
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generated Rules -->
|
||||||
|
<% if @generated_rules.any? %>
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<div class="px-4 py-5 sm:px-6">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">🔧 Generated Rules</h3>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||||
|
Specific rules created by this policy that are enforced by baffle-agents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200">
|
||||||
|
<ul class="divide-y divide-gray-200">
|
||||||
|
<% @generated_rules.each do |rule| %>
|
||||||
|
<li class="hover:bg-gray-50">
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<%= link_to "📋", rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="text-sm font-medium text-gray-900">
|
||||||
|
Rule #<%= rule.id %> - <%= rule.network_range&.cidr || "Unknown" %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<%= rule.action.upcase %> • Created <%= time_ago_in_words(rule.created_at) %> ago
|
||||||
|
<% if rule.redirect_action? %>
|
||||||
|
• Redirect to <%= rule.redirect_url %>
|
||||||
|
<% elsif rule.challenge_action? %>
|
||||||
|
• <%= rule.challenge_type.humanize %> challenge
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<% if rule.active? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
21
db/migrate/20251110094801_create_data_imports.rb
Normal file
21
db/migrate/20251110094801_create_data_imports.rb
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
class CreateDataImports < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :data_imports do |t|
|
||||||
|
t.string :import_type, null: false, comment: "ASN or Country import"
|
||||||
|
t.string :status, null: false, default: "pending", comment: "pending, processing, completed, failed"
|
||||||
|
t.string :filename, null: false
|
||||||
|
t.integer :total_records, default: 0
|
||||||
|
t.integer :processed_records, default: 0
|
||||||
|
t.integer :failed_records, default: 0
|
||||||
|
t.text :error_message
|
||||||
|
t.json :import_stats, default: {}, comment: "Detailed import statistics"
|
||||||
|
t.datetime :started_at
|
||||||
|
t.datetime :completed_at
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :data_imports, :status
|
||||||
|
add_index :data_imports, :import_type
|
||||||
|
add_index :data_imports, :created_at
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# This migration comes from active_storage (originally 20170806125915)
|
||||||
|
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
# Use Active Record's configured type for primary and foreign keys
|
||||||
|
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||||
|
|
||||||
|
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||||
|
t.string :key, null: false
|
||||||
|
t.string :filename, null: false
|
||||||
|
t.string :content_type
|
||||||
|
t.text :metadata
|
||||||
|
t.string :service_name, null: false
|
||||||
|
t.bigint :byte_size, null: false
|
||||||
|
t.string :checksum
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :key ], unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||||
|
t.references :blob, null: false, type: foreign_key_type
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_variant_records, id: primary_key_type do |t|
|
||||||
|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||||
|
t.string :variation_digest, null: false
|
||||||
|
|
||||||
|
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def primary_and_foreign_key_types
|
||||||
|
config = Rails.configuration.generators
|
||||||
|
setting = config.options[config.orm][:primary_key_type]
|
||||||
|
primary_key_type = setting || :primary_key
|
||||||
|
foreign_key_type = setting || :bigint
|
||||||
|
[ primary_key_type, foreign_key_type ]
|
||||||
|
end
|
||||||
|
end
|
||||||
6
db/migrate/20251110231820_add_tags_to_events.rb
Normal file
6
db/migrate/20251110231820_add_tags_to_events.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class AddTagsToEvents < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :events, :tags, :jsonb, default: [], null: false
|
||||||
|
add_index :events, :tags, using: :gin
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user