Add saved firewall configuration functionality

- Implemented the ability to save, view, apply, and delete firewall configurations for users.
- Created a new model for FirewallConfiguration and corresponding database migration.
- Enhanced the firewall view to include a tab for managing saved configurations.
This commit is contained in:
2025-12-08 10:26:32 -05:00
parent 596ac84599
commit e1d2805271
4 changed files with 910 additions and 0 deletions

View File

@@ -4,8 +4,10 @@ namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Carbon\Carbon;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Paymenter\Extensions\Servers\Proxmox\Models\FirewallConfiguration;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
use Paymenter\Extensions\Servers\Proxmox\Services\CoreRouterClient;
@@ -47,6 +49,19 @@ class Firewall extends Component
public bool $showDeleteRuleModal = false;
public ?int $deletingRuleIndex = null;
// Saved Configurations
public array $savedConfigurations = [];
public bool $showSaveConfigModal = false;
public string $newConfigName = '';
public bool $showViewConfigModal = false;
public ?FirewallConfiguration $viewingConfiguration = null;
public array $configCompatibilityIssues = [];
public bool $showDeleteConfigModal = false;
public ?int $deletingConfigId = null;
public bool $showApplyConfigModal = false;
public ?FirewallConfiguration $applyingConfiguration = null;
public array $applyCompatibilityWarnings = [];
// Available Proxmox Macros
public array $availableMacros = [
'' => 'None',
@@ -256,6 +271,7 @@ class Firewall extends Component
$this->loadFirewallRules();
$this->loadAliases();
$this->loadIPSets();
$this->loadSavedConfigurations();
// Don't load logs on mount - load lazily when logs tab is viewed
}
@@ -1846,6 +1862,432 @@ class Firewall extends Component
return round($bytes / pow(1024, $power), 2) . ' ' . $units[$power];
}
// ==================== SAVED CONFIGURATIONS ====================
/**
* Load all saved firewall configurations for the current user.
*/
protected function loadSavedConfigurations(): void
{
$userId = Auth::id();
if (!$userId) {
$this->savedConfigurations = [];
return;
}
$this->savedConfigurations = FirewallConfiguration::forUser($userId)
->orderBy('created_at', 'desc')
->get()
->toArray();
}
/**
* Open the save configuration modal.
*/
public function openSaveConfigModal(): void
{
$this->newConfigName = '';
$this->showSaveConfigModal = true;
}
/**
* Close the save configuration modal.
*/
public function closeSaveConfigModal(): void
{
$this->newConfigName = '';
$this->showSaveConfigModal = false;
}
/**
* Save the current firewall configuration.
*/
public function saveConfiguration(): void
{
$this->validate([
'newConfigName' => 'required|string|min:1|max:255',
]);
$userId = Auth::id();
if (!$userId) {
$this->error = 'You must be logged in to save configurations';
$this->showErrorModal = true;
return;
}
$this->loading = true;
try {
// Capture the current firewall state
$configData = [
'rules' => $this->rules,
'policyIn' => $this->policyIn,
'policyOut' => $this->policyOut,
];
FirewallConfiguration::create([
'user_id' => $userId,
'configuration_name' => trim($this->newConfigName),
'firewall_rules' => $configData['rules'],
'incoming_policy' => $configData['policyIn'],
'outgoing_policy' => $configData['policyOut'],
]);
$this->notify('Configuration saved successfully', 'success');
$this->closeSaveConfigModal();
$this->loadSavedConfigurations();
} catch (\Exception $e) {
Log::error('Failed to save firewall configuration: ' . $e->getMessage());
$this->error = 'Failed to save configuration: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
/**
* Open the view configuration modal.
*/
public function viewConfiguration(int $configId): void
{
$userId = Auth::id();
if (!$userId) {
return;
}
$config = FirewallConfiguration::forUser($userId)->find($configId);
if (!$config) {
$this->error = 'Configuration not found or access denied';
$this->showErrorModal = true;
return;
}
$this->viewingConfiguration = $config;
$this->configCompatibilityIssues = $this->checkConfigurationCompatibility($config);
$this->showViewConfigModal = true;
}
/**
* Close the view configuration modal.
*/
public function closeViewConfigModal(): void
{
$this->viewingConfiguration = null;
$this->configCompatibilityIssues = [];
$this->showViewConfigModal = false;
}
/**
* Check configuration compatibility with the current VPS.
*/
protected function checkConfigurationCompatibility(FirewallConfiguration $config): array
{
$issues = [];
$savedRules = $config->firewall_rules;
if (!is_array($savedRules)) {
return $issues;
}
// Get current available interfaces
$availableInterfaces = collect($this->networkAdapters)->pluck('name')->toArray();
foreach ($savedRules as $index => $rule) {
$ruleIssues = [];
// Check if interface exists on current VPS
if (!empty($rule['iface']) && !in_array($rule['iface'], $availableInterfaces)) {
$ruleIssues[] = "Interface '{$rule['iface']}' does not exist on this VPS";
}
// Check if source IP is available (if it references a specific IP)
if (!empty($rule['source'])) {
$sourceIp = $rule['source'];
// Skip validation for CIDR ranges, aliases, or "any"
if (!str_contains($sourceIp, '/') &&
!str_starts_with($sourceIp, '+') &&
$sourceIp !== 'any' &&
filter_var($sourceIp, FILTER_VALIDATE_IP)) {
// This is a specific IP, check if it's one of our IPs
$ourIps = collect($this->availableIps)->pluck('ip')->toArray();
if (!in_array($sourceIp, $ourIps)) {
// Not one of our IPs, which is fine for source (external IPs)
}
}
}
// Check if destination IP is available
if (!empty($rule['dest'])) {
$destIp = $rule['dest'];
// Skip validation for CIDR ranges, aliases, or "any"
if (!str_contains($destIp, '/') &&
!str_starts_with($destIp, '+') &&
$destIp !== 'any' &&
filter_var($destIp, FILTER_VALIDATE_IP)) {
// This is a specific IP, check if it's one of our IPs
$ourIps = collect($this->availableIps)->pluck('ip')->toArray();
if (!in_array($destIp, $ourIps)) {
$ruleIssues[] = "Destination IP '{$destIp}' is not assigned to this VPS";
}
}
}
if (!empty($ruleIssues)) {
$issues[] = [
'rule_index' => $index,
'rule' => $rule,
'issues' => $ruleIssues,
];
}
}
return $issues;
}
/**
* Open the confirm apply configuration modal.
*/
public function confirmApplyConfiguration(int $configId): void
{
$userId = Auth::id();
if (!$userId) {
return;
}
$config = FirewallConfiguration::forUser($userId)->find($configId);
if (!$config) {
$this->error = 'Configuration not found or access denied';
$this->showErrorModal = true;
return;
}
$this->applyingConfiguration = $config;
$this->applyCompatibilityWarnings = $this->checkConfigurationCompatibility($config);
$this->showApplyConfigModal = true;
}
/**
* Close the apply configuration modal.
*/
public function closeApplyConfigModal(): void
{
$this->applyingConfiguration = null;
$this->applyCompatibilityWarnings = [];
$this->showApplyConfigModal = false;
}
/**
* Apply a saved configuration to the current VPS.
*/
public function applyConfiguration(): void
{
if (!$this->applyingConfiguration) {
return;
}
$userId = Auth::id();
if (!$userId) {
$this->error = 'You must be logged in to apply configurations';
$this->showErrorModal = true;
return;
}
// Verify ownership
if ($this->applyingConfiguration->user_id !== $userId) {
$this->error = 'Access denied';
$this->showErrorModal = true;
return;
}
$this->loading = true;
$incompatibleRulesDisabled = 0;
try {
$config = $this->applyingConfiguration;
$savedRules = $config->firewall_rules;
$availableInterfaces = collect($this->networkAdapters)->pluck('name')->toArray();
$ourIps = collect($this->availableIps)->pluck('ip')->toArray();
// Step 1: Update policies
$this->policyIn = $config->incoming_policy;
$this->policyOut = $config->outgoing_policy;
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options',
'put',
[
'policy_in' => $this->policyIn,
'policy_out' => $this->policyOut,
'dhcp' => 0,
'ndp' => 1,
]
);
if (!$response->successful()) {
throw new \Exception('Failed to update firewall policies');
}
// Step 2: Delete all existing rules
foreach (array_reverse($this->rules) as $rule) {
if (isset($rule['pos'])) {
$this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $rule['pos'],
'delete'
);
}
}
// Step 3: Create new rules from saved configuration
if (is_array($savedRules)) {
foreach ($savedRules as $savedRule) {
$isCompatible = true;
$rulePayload = [];
// Check interface compatibility
if (!empty($savedRule['iface']) && !in_array($savedRule['iface'], $availableInterfaces)) {
$isCompatible = false;
}
// Check destination IP compatibility
if (!empty($savedRule['dest'])) {
$destIp = $savedRule['dest'];
if (!str_contains($destIp, '/') &&
!str_starts_with($destIp, '+') &&
$destIp !== 'any' &&
filter_var($destIp, FILTER_VALIDATE_IP) &&
!in_array($destIp, $ourIps)) {
$isCompatible = false;
}
}
// Build the rule payload
$rulePayload['action'] = $savedRule['action'] ?? 'ACCEPT';
$rulePayload['type'] = $savedRule['type'] ?? 'in';
// If incompatible, disable the rule
if (!$isCompatible) {
$rulePayload['enable'] = 0;
$incompatibleRulesDisabled++;
} else {
$rulePayload['enable'] = isset($savedRule['enable']) ? (int)$savedRule['enable'] : 1;
}
// Add optional fields
if (!empty($savedRule['iface']) && in_array($savedRule['iface'], $availableInterfaces)) {
$rulePayload['iface'] = $savedRule['iface'];
}
if (!empty($savedRule['source'])) {
$rulePayload['source'] = $savedRule['source'];
}
if (!empty($savedRule['dest']) && $isCompatible) {
$rulePayload['dest'] = $savedRule['dest'];
}
if (!empty($savedRule['proto'])) {
$rulePayload['proto'] = $savedRule['proto'];
}
if (!empty($savedRule['dport'])) {
$rulePayload['dport'] = $savedRule['dport'];
}
if (!empty($savedRule['sport'])) {
$rulePayload['sport'] = $savedRule['sport'];
}
if (!empty($savedRule['comment'])) {
$rulePayload['comment'] = $savedRule['comment'];
}
if (!empty($savedRule['log']) && $savedRule['log'] !== 'nolog') {
$rulePayload['log'] = $savedRule['log'];
}
// Create the rule
$this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules',
'post',
$rulePayload
);
}
}
// Reload the current state
$this->loadFirewallOptions();
$this->loadFirewallRules();
$message = 'Configuration applied successfully';
if ($incompatibleRulesDisabled > 0) {
$message .= ". {$incompatibleRulesDisabled} rule(s) were disabled due to incompatibility with this VPS.";
$this->notify($message, 'warning');
} else {
$this->notify($message, 'success');
}
$this->closeApplyConfigModal();
} catch (\Exception $e) {
Log::error('Failed to apply firewall configuration: ' . $e->getMessage());
$this->error = 'Failed to apply configuration: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
/**
* Confirm deletion of a saved configuration.
*/
public function confirmDeleteConfiguration(int $configId): void
{
$this->deletingConfigId = $configId;
$this->showDeleteConfigModal = true;
}
/**
* Cancel deletion of a saved configuration.
*/
public function cancelDeleteConfiguration(): void
{
$this->deletingConfigId = null;
$this->showDeleteConfigModal = false;
}
/**
* Delete a saved configuration.
*/
public function deleteConfiguration(): void
{
if (!$this->deletingConfigId) {
return;
}
$userId = Auth::id();
if (!$userId) {
$this->error = 'You must be logged in to delete configurations';
$this->showErrorModal = true;
return;
}
$this->loading = true;
try {
$config = FirewallConfiguration::forUser($userId)->find($this->deletingConfigId);
if (!$config) {
$this->error = 'Configuration not found or access denied';
$this->showErrorModal = true;
return;
}
$config->delete();
$this->notify('Configuration deleted successfully', 'success');
$this->cancelDeleteConfiguration();
$this->loadSavedConfigurations();
} catch (\Exception $e) {
Log::error('Failed to delete firewall configuration: ' . $e->getMessage());
$this->error = 'Failed to delete configuration: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
public function render()
{
return view('proxmox::firewall')->layoutData([

View File

@@ -0,0 +1,50 @@
<?php
namespace Paymenter\Extensions\Servers\Proxmox\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FirewallConfiguration extends Model
{
protected $table = 'ext_proxmox_firewall_configurations';
protected $fillable = [
'user_id',
'configuration_name',
'firewall_rules',
'incoming_policy',
'outgoing_policy',
];
protected $casts = [
'firewall_rules' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Get the user that owns this firewall configuration.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Scope a query to only include configurations for a specific user.
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
/**
* Get the rules count for display.
*/
public function getRulesCountAttribute(): int
{
return is_array($this->firewall_rules) ? count($this->firewall_rules) : 0;
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ext_proxmox_firewall_configurations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('configuration_name');
$table->json('firewall_rules');
$table->string('incoming_policy', 10)->default('DROP');
$table->string('outgoing_policy', 10)->default('ACCEPT');
$table->timestamps();
// Index for faster lookups by user
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ext_proxmox_firewall_configurations');
}
};

View File

@@ -365,6 +365,16 @@
</div>
<div x-show="activeTab === 'logs'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
</button>
<button @click="activeTab = 'saved'"
class="px-6 py-4 text-sm font-semibold transition-all relative whitespace-nowrap"
:class="activeTab === 'saved' ? 'text-primary' : 'text-base/60 hover:text-base'">
<div class="flex items-center gap-2">
<x-ri-save-3-line class="size-4" />
<span>Saved Configurations</span>
<span class="px-2 py-0.5 bg-primary/10 text-primary rounded-full text-xs font-bold">{{ count($savedConfigurations) }}</span>
</div>
<div x-show="activeTab === 'saved'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
</button>
</div>
{{-- Tab Contents --}}
@@ -1384,6 +1394,87 @@
</div>
@endif
</div>
{{-- SAVED CONFIGURATIONS TAB --}}
<div x-show="activeTab === 'saved'" x-transition>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold">Saved Configurations</h3>
<p class="text-sm text-base/60 mt-1">Save and reuse firewall configurations across your VPS instances</p>
</div>
<button type="button" wire:click="openSaveConfigModal" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium flex items-center gap-2">
<x-ri-save-3-line class="size-4" />
Save Current Configuration
</button>
</div>
@if(count($savedConfigurations) > 0)
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-neutral/20">
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Configuration Name</th>
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Rules</th>
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Policies</th>
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Created</th>
<th class="text-center py-3 px-4 text-xs font-semibold text-base/70 uppercase">Actions</th>
</tr>
</thead>
<tbody>
@foreach($savedConfigurations as $config)
<tr class="border-b border-neutral/10 hover:bg-background-secondary/30 transition-colors">
<td class="py-3 px-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<x-ri-shield-star-line class="size-4 text-primary" />
</div>
<span class="font-medium">{{ $config['configuration_name'] }}</span>
</div>
</td>
<td class="py-3 px-4">
<span class="px-2 py-1 bg-info/10 text-info rounded-full text-xs font-medium">
{{ is_array($config['firewall_rules']) ? count($config['firewall_rules']) : 0 }} rules
</span>
</td>
<td class="py-3 px-4">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs rounded {{ $config['incoming_policy'] === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
IN: {{ $config['incoming_policy'] }}
</span>
<span class="px-2 py-0.5 text-xs rounded {{ $config['outgoing_policy'] === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
OUT: {{ $config['outgoing_policy'] }}
</span>
</div>
</td>
<td class="py-3 px-4 text-sm text-base/70">
{{ \Carbon\Carbon::parse($config['created_at'])->format('M d, Y H:i') }}
</td>
<td class="py-3 px-4">
<div class="flex items-center justify-center gap-2">
<button type="button" wire:click="confirmApplyConfiguration({{ $config['id'] }})" class="p-2 text-success hover:bg-success/10 rounded-lg transition-colors" title="Apply Configuration">
<x-ri-play-circle-line class="size-5" />
</button>
<button type="button" wire:click="viewConfiguration({{ $config['id'] }})" class="p-2 text-info hover:bg-info/10 rounded-lg transition-colors" title="View Details">
<x-ri-eye-line class="size-5" />
</button>
<button type="button" wire:click="confirmDeleteConfiguration({{ $config['id'] }})" class="p-2 text-error hover:bg-error/10 rounded-lg transition-colors" title="Delete Configuration">
<x-ri-delete-bin-line class="size-5" />
</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-12 text-base/60">
<x-ri-save-3-line class="size-16 mx-auto mb-4 opacity-30" />
<h3 class="text-lg font-semibold mb-2">No Saved Configurations</h3>
<p class="text-sm">Save your current firewall configuration to reuse it later or apply it to other VPS instances</p>
</div>
@endif
</div>
</div>
</div>
@else
@@ -1853,6 +1944,298 @@
@endif
</div>
{{-- Save Configuration Modal --}}
@if($showSaveConfigModal)
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
<button wire:click="closeSaveConfigModal" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
<x-ri-close-line class="size-5" />
</button>
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<x-ri-save-3-line class="size-6 text-primary" />
</div>
<div>
<h3 class="text-xl font-bold mb-1">Save Current Configuration</h3>
<p class="text-sm text-base/70">Save your current firewall rules and policies as a reusable configuration</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Configuration Name</label>
<input type="text" wire:model="newConfigName" placeholder="e.g., Web Server Rules"
class="w-full px-4 py-2 bg-background border border-neutral rounded-lg focus:border-primary focus:outline-none transition-colors">
@error('newConfigName')
<p class="text-error text-xs mt-1">{{ $message }}</p>
@enderror
</div>
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4">
<h4 class="text-sm font-semibold mb-3">Configuration Summary</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="flex items-center justify-between p-2 bg-background rounded">
<span class="text-base/60">Rules</span>
<span class="font-semibold">{{ count($rules) }}</span>
</div>
<div class="flex items-center justify-between p-2 bg-background rounded">
<span class="text-base/60">Incoming Policy</span>
<span class="font-semibold {{ $policyIn === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyIn }}</span>
</div>
<div class="flex items-center justify-between p-2 bg-background rounded">
<span class="text-base/60">Outgoing Policy</span>
<span class="font-semibold {{ $policyOut === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyOut }}</span>
</div>
</div>
</div>
</div>
<div class="flex gap-3 justify-end mt-6 pt-4 border-t border-neutral/20">
<button wire:click="closeSaveConfigModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
Cancel
</button>
<button wire:click="saveConfiguration" wire:loading.attr="disabled" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
<span wire:loading.remove wire:target="saveConfiguration">Save Configuration</span>
<span wire:loading wire:target="saveConfiguration">Saving...</span>
</button>
</div>
</div>
</div>
@endif
{{-- View Configuration Modal --}}
@if($showViewConfigModal && $viewingConfiguration)
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="relative w-full max-w-4xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-hidden flex flex-col">
<div class="p-6 border-b border-neutral/20">
<button wire:click="closeViewConfigModal" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
<x-ri-close-line class="size-5" />
</button>
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-full bg-info/10 flex items-center justify-center flex-shrink-0">
<x-ri-eye-line class="size-6 text-info" />
</div>
<div>
<h3 class="text-xl font-bold mb-1">{{ $viewingConfiguration->configuration_name }}</h3>
<p class="text-sm text-base/70">Created on {{ $viewingConfiguration->created_at->format('M d, Y H:i') }}</p>
</div>
</div>
</div>
<div class="p-6 overflow-y-auto flex-1">
{{-- Policies --}}
<div class="mb-6">
<h4 class="text-sm font-semibold mb-3">Traffic Policies</h4>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-background rounded-lg border border-neutral/20">
<div class="flex items-center justify-between">
<span class="text-sm text-base/60">Incoming Traffic</span>
<span class="px-3 py-1 rounded-full text-sm font-semibold {{ $viewingConfiguration->incoming_policy === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
{{ $viewingConfiguration->incoming_policy }}
</span>
</div>
</div>
<div class="p-4 bg-background rounded-lg border border-neutral/20">
<div class="flex items-center justify-between">
<span class="text-sm text-base/60">Outgoing Traffic</span>
<span class="px-3 py-1 rounded-full text-sm font-semibold {{ $viewingConfiguration->outgoing_policy === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
{{ $viewingConfiguration->outgoing_policy }}
</span>
</div>
</div>
</div>
</div>
{{-- Compatibility Warnings --}}
@if(count($configCompatibilityIssues) > 0)
<div class="mb-6 p-4 bg-warning/10 border border-warning/30 rounded-lg">
<div class="flex items-start gap-3">
<x-ri-error-warning-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
<div>
<h4 class="text-sm font-semibold text-warning mb-2">Compatibility Issues</h4>
<p class="text-xs text-warning/80 mb-2">The following rules may not be compatible with this VPS:</p>
<ul class="text-xs text-warning/80 space-y-1">
@foreach($configCompatibilityIssues as $issue)
<li>• Rule #{{ $issue['rule_index'] + 1 }}: {{ implode(', ', $issue['issues']) }}</li>
@endforeach
</ul>
</div>
</div>
</div>
@endif
{{-- Rules --}}
<div>
<h4 class="text-sm font-semibold mb-3">Firewall Rules ({{ count($viewingConfiguration->firewall_rules) }})</h4>
@if(count($viewingConfiguration->firewall_rules) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-neutral/20">
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">#</th>
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Action</th>
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Direction</th>
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Source</th>
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Destination</th>
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Protocol</th>
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Port</th>
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Status</th>
</tr>
</thead>
<tbody>
@foreach($viewingConfiguration->firewall_rules as $index => $rule)
@php
$hasIssue = collect($configCompatibilityIssues)->contains('rule_index', $index);
@endphp
<tr class="border-b border-neutral/10 {{ $hasIssue ? 'bg-warning/5' : '' }}">
<td class="py-2 px-3 text-base/60">{{ $index + 1 }}</td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-medium {{ ($rule['action'] ?? 'ACCEPT') === 'ACCEPT' ? 'bg-success/10 text-success' : 'bg-error/10 text-error' }}">
{{ $rule['action'] ?? 'ACCEPT' }}
</span>
</td>
<td class="py-2 px-3">{{ strtoupper($rule['type'] ?? '-') }}</td>
<td class="py-2 px-3 font-mono text-xs">{{ $rule['source'] ?? 'any' }}</td>
<td class="py-2 px-3 font-mono text-xs">{{ $rule['dest'] ?? 'any' }}</td>
<td class="py-2 px-3">{{ $rule['proto'] ?? '-' }}</td>
<td class="py-2 px-3 font-mono text-xs">{{ $rule['dport'] ?? '-' }}</td>
<td class="py-2 px-3">
@if($hasIssue)
<span class="px-2 py-0.5 rounded text-xs font-medium bg-warning/10 text-warning">Incompatible</span>
@elseif(isset($rule['enable']) && $rule['enable'] == 0)
<span class="px-2 py-0.5 rounded text-xs font-medium bg-base/10 text-base/60">Disabled</span>
@else
<span class="px-2 py-0.5 rounded text-xs font-medium bg-success/10 text-success">Enabled</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-sm text-base/60 text-center py-4">No rules in this configuration</p>
@endif
</div>
</div>
<div class="p-4 border-t border-neutral/20 bg-background/50">
<div class="flex gap-3 justify-end">
<button wire:click="closeViewConfigModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
Close
</button>
<button wire:click="confirmApplyConfiguration({{ $viewingConfiguration->id }})" class="px-4 py-2 bg-success text-white rounded-lg hover:bg-success/90 transition-colors text-sm font-medium flex items-center gap-2">
<x-ri-play-circle-line class="size-4" />
Apply Configuration
</button>
</div>
</div>
</div>
</div>
@endif
{{-- Apply Configuration Modal --}}
@if($showApplyConfigModal && $applyingConfiguration)
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
<button wire:click="closeApplyConfigModal" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
<x-ri-close-line class="size-5" />
</button>
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 rounded-full bg-success/10 flex items-center justify-center flex-shrink-0">
<x-ri-play-circle-line class="size-6 text-success" />
</div>
<div>
<h3 class="text-xl font-bold mb-1">Apply Configuration</h3>
<p class="text-sm text-base/70">Apply "{{ $applyingConfiguration->configuration_name }}" to this VPS</p>
</div>
</div>
<div class="bg-warning/10 border border-warning/30 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<x-ri-error-warning-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-semibold text-warning mb-1">Warning</p>
<p class="text-warning/80">This will replace all existing firewall rules with the rules from this configuration. This action cannot be undone.</p>
</div>
</div>
</div>
@if(count($applyCompatibilityWarnings) > 0)
<div class="bg-info/10 border border-info/30 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<x-ri-information-line class="size-5 text-info flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-semibold text-info mb-1">Compatibility Notice</p>
<p class="text-info/80">{{ count($applyCompatibilityWarnings) }} rule(s) in this configuration are not fully compatible with this VPS and will be disabled.</p>
</div>
</div>
</div>
@endif
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4 mb-6">
<h4 class="text-sm font-semibold mb-3">Configuration Details</h4>
<div class="grid grid-cols-3 gap-3 text-sm">
<div class="text-center p-2 bg-background rounded">
<p class="text-xs text-base/60 mb-1">Rules</p>
<p class="font-semibold">{{ count($applyingConfiguration->firewall_rules) }}</p>
</div>
<div class="text-center p-2 bg-background rounded">
<p class="text-xs text-base/60 mb-1">IN Policy</p>
<p class="font-semibold {{ $applyingConfiguration->incoming_policy === 'DROP' ? 'text-error' : 'text-success' }}">{{ $applyingConfiguration->incoming_policy }}</p>
</div>
<div class="text-center p-2 bg-background rounded">
<p class="text-xs text-base/60 mb-1">OUT Policy</p>
<p class="font-semibold {{ $applyingConfiguration->outgoing_policy === 'DROP' ? 'text-error' : 'text-success' }}">{{ $applyingConfiguration->outgoing_policy }}</p>
</div>
</div>
</div>
<div class="flex gap-3 justify-end">
<button wire:click="closeApplyConfigModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
Cancel
</button>
<button wire:click="applyConfiguration" wire:loading.attr="disabled" class="px-4 py-2 bg-success text-white rounded-lg hover:bg-success/90 transition-colors text-sm font-medium">
<span wire:loading.remove wire:target="applyConfiguration">Apply Configuration</span>
<span wire:loading wire:target="applyConfiguration">Applying...</span>
</button>
</div>
</div>
</div>
@endif
{{-- Delete Configuration Modal --}}
@if($showDeleteConfigModal && $deletingConfigId)
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
<button wire:click="cancelDeleteConfiguration" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
<x-ri-close-line class="size-5" />
</button>
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-full bg-error/10 flex items-center justify-center flex-shrink-0">
<x-ri-delete-bin-line class="size-6 text-error" />
</div>
<div>
<h3 class="text-xl font-bold mb-2">Delete Configuration</h3>
<p class="text-sm text-base/70">Are you sure you want to delete this saved configuration? This action cannot be undone.</p>
</div>
</div>
<div class="flex gap-3 justify-end">
<button wire:click="cancelDeleteConfiguration" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
Cancel
</button>
<button wire:click="deleteConfiguration" wire:loading.attr="disabled"
style="padding: 0.5rem 1rem; background-color: #ef4444; color: white; border-radius: 0.5rem; transition: all 0.2s; font-size: 0.875rem; font-weight: 500;"
onmouseover="this.style.backgroundColor='#dc2626'"
onmouseout="this.style.backgroundColor='#ef4444'">
<span wire:loading.remove wire:target="deleteConfiguration">Delete</span>
<span wire:loading wire:target="deleteConfiguration">Deleting...</span>
</button>
</div>
</div>
</div>
@endif
@script
<script>
// IP location helper functions