feat: implement drag-and-drop functionality for reordering firewall rules in Proxmox view

This commit is contained in:
2025-12-04 11:25:35 -05:00
parent 33144e96ae
commit 7b1bfe8b23
2 changed files with 182 additions and 32 deletions

View File

@@ -481,6 +481,54 @@ class Firewall extends Component
}
}
public function reorderRules(int $fromPos, int $toPos): void
{
try {
// Proxmox API uses PUT with moveto parameter to reorder rules
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $fromPos,
'put',
['moveto' => $toPos]
);
if ($response->successful()) {
$this->notify('Rule reordered successfully', 'success');
$this->loadFirewallRules();
} else {
$message = $response->json()['errors'][0]['message'] ?? 'Unknown error';
$this->error = 'Failed to reorder rule: ' . $message;
$this->showErrorModal = true;
\Log::error('Failed to reorder firewall rule', [
'from' => $fromPos,
'to' => $toPos,
'response' => $response->json()
]);
}
} catch (\Exception $e) {
$errorMessage = $e->getMessage();
if (method_exists($e, 'response') && $e->response()) {
try {
$errorData = $e->response()->json();
if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) {
$errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage;
} elseif (isset($errorData['message'])) {
$errorMessage = $errorData['message'];
}
} catch (\Exception $jsonError) {
// Response might not be JSON
}
}
$this->error = 'Failed to reorder rule: ' . $errorMessage;
$this->showErrorModal = true;
\Log::error('Exception reordering firewall rule', [
'from' => $fromPos,
'to' => $toPos,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
}
}
public function loadAliases(): void
{
try {

View File

@@ -92,6 +92,36 @@
padding-left: 2.75rem !important;
}
/* Drag and Drop Styles */
.rule-row {
transition: all 0.2s ease;
cursor: move;
}
.rule-row.dragging {
opacity: 0.5;
background-color: rgba(var(--color-primary), 0.1);
}
.rule-row.drag-over {
border-top: 3px solid rgba(var(--color-primary), 1);
}
.drag-handle {
cursor: grab;
color: rgba(var(--color-base), 0.4);
transition: color 0.2s;
display: flex;
align-items: center;
padding: 0.5rem;
}
.drag-handle:hover {
color: rgba(var(--color-primary), 1);
}
.drag-handle:active {
cursor: grabbing;
}
.rule-row:hover .drag-handle {
color: rgba(var(--color-base), 0.7);
}
/* Firewall Log Filter Buttons */
.firewall-filter-label {
font-size: 0.65rem;
@@ -517,10 +547,79 @@
<!-- Rules Table -->
@if(count($rules) > 0)
<div class="overflow-x-auto">
<div class="overflow-x-auto"
x-data="{
rules: @js($rules),
draggedIndex: null,
dragOverIndex: null,
startDrag(event, index) {
this.draggedIndex = index;
event.target.closest('tr').classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/html', event.target.closest('tr').innerHTML);
},
endDrag(event) {
event.target.closest('tr').classList.remove('dragging');
this.draggedIndex = null;
this.dragOverIndex = null;
// Remove all drag-over classes
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
},
dragOver(event, index) {
event.preventDefault();
if (this.draggedIndex !== null && this.draggedIndex !== index) {
this.dragOverIndex = index;
}
},
dragLeave(event) {
event.target.closest('tr')?.classList.remove('drag-over');
},
async drop(event, targetIndex) {
event.preventDefault();
event.target.closest('tr')?.classList.remove('drag-over');
if (this.draggedIndex === null || this.draggedIndex === targetIndex) {
return;
}
const draggedRule = this.rules[this.draggedIndex];
const targetRule = this.rules[targetIndex];
// Optimistically update UI
const movedRule = this.rules.splice(this.draggedIndex, 1)[0];
this.rules.splice(targetIndex, 0, movedRule);
// Update positions in the array
this.rules.forEach((rule, idx) => {
rule.pos = idx;
});
// Call Livewire to persist the change
try {
await @this.reorderRules(draggedRule.pos, targetRule.pos);
} catch (error) {
console.error('Failed to reorder rules:', error);
// Reload rules on error
window.location.reload();
}
},
getActionClass(action) {
if (action === 'ACCEPT') return 'bg-success/20 text-success';
if (action === 'REJECT') return 'bg-warning/20 text-warning';
if (action === 'DROP') return 'bg-error/20 text-error';
return '';
}
}">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-neutral">
<th class="text-left py-3 px-2 font-semibold text-sm w-8"></th>
<th class="text-left py-3 px-4 font-semibold text-sm">Position</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Action</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Source</th>
@@ -532,50 +631,53 @@
</tr>
</thead>
<tbody>
@foreach($rules as $rule)
<tr class="border-b border-neutral/20 hover:bg-background-secondary/50">
<td class="py-3 px-4 text-sm">{{ $rule['pos'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">
<span class="px-2 py-1 rounded text-xs font-medium
{{ ($rule['action'] ?? '') === 'ACCEPT' ? 'bg-success/20 text-success' : '' }}
{{ ($rule['action'] ?? '') === 'REJECT' ? 'bg-warning/20 text-warning' : '' }}
{{ ($rule['action'] ?? '') === 'DROP' ? 'bg-error/20 text-error' : '' }}">
{{ $rule['action'] ?? '-' }}
</span>
<template x-for="(rule, index) in rules" :key="rule.pos">
<tr class="rule-row border-b border-neutral/20 hover:bg-background-secondary/50"
draggable="true"
@dragstart="startDrag($event, index)"
@dragend="endDrag($event)"
@dragover="dragOver($event, index)"
@dragleave="dragLeave($event)"
@drop="drop($event, index)"
:class="{ 'drag-over': dragOverIndex === index }">
<td class="py-3 px-2 text-sm">
<div class="drag-handle" title="Drag to reorder">
<x-ri-menu-line class="size-4" />
</div>
</td>
<td class="py-3 px-4 text-sm">{{ $rule['source'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">{{ $rule['dest'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">{{ strtoupper($rule['proto'] ?? 'any') }}</td>
<td class="py-3 px-4 text-sm">{{ $rule['dport'] ?? ($rule['sport'] ?? '-') }}</td>
<td class="py-3 px-4 text-sm" x-text="rule.pos ?? '-'"></td>
<td class="py-3 px-4 text-sm">
@if(isset($rule['enable']) && $rule['enable'] == 0)
<span class="px-2 py-1 rounded text-xs font-medium bg-base/20 text-base/60">Disabled</span>
@else
<span class="px-2 py-1 rounded text-xs font-medium bg-success/20 text-success">Enabled</span>
@endif
<span class="px-2 py-1 rounded text-xs font-medium"
:class="getActionClass(rule.action)"
x-text="rule.action ?? '-'"></span>
</td>
<td class="py-3 px-4 text-sm" x-text="rule.source ?? '-'"></td>
<td class="py-3 px-4 text-sm" x-text="rule.dest ?? '-'"></td>
<td class="py-3 px-4 text-sm" x-text="(rule.proto ?? 'any').toUpperCase()"></td>
<td class="py-3 px-4 text-sm" x-text="rule.dport ?? rule.sport ?? '-'"></td>
<td class="py-3 px-4 text-sm">
<span x-show="rule.enable == 0" class="px-2 py-1 rounded text-xs font-medium bg-base/20 text-base/60">Disabled</span>
<span x-show="rule.enable != 0" class="px-2 py-1 rounded text-xs font-medium bg-success/20 text-success">Enabled</span>
</td>
<td class="py-3 px-4 text-sm">
<div class="flex gap-2">
<button
wire:click="toggleRule({{ $rule['pos'] ?? 0 }})"
@click="$wire.toggleRule(rule.pos)"
wire:loading.attr="disabled"
class="transition-colors
{{ (isset($rule['enable']) && $rule['enable'] == 0) ? 'text-warning hover:text-warning/80' : 'text-success hover:text-success/80' }}"
title="{{ (isset($rule['enable']) && $rule['enable'] == 0) ? 'Enable rule' : 'Disable rule' }}">
@if(isset($rule['enable']) && $rule['enable'] == 0)
<x-ri-toggle-line class="size-4" />
@else
<x-ri-toggle-fill class="size-4" />
@endif
class="transition-colors"
:class="rule.enable == 0 ? 'text-warning hover:text-warning/80' : 'text-success hover:text-success/80'"
:title="rule.enable == 0 ? 'Enable rule' : 'Disable rule'">
<x-ri-toggle-line x-show="rule.enable == 0" class="size-4" />
<x-ri-toggle-fill x-show="rule.enable != 0" class="size-4" />
</button>
<button
wire:click="startEditRule({{ $rule['pos'] ?? 0 }})"
@click="$wire.startEditRule(rule.pos)"
class="text-primary hover:text-primary/80 transition-colors"
title="Edit rule">
<x-ri-edit-line class="size-4" />
</button>
<button
wire:click="confirmDeleteRule({{ $rule['pos'] ?? 0 }})"
@click="$wire.confirmDeleteRule(rule.pos)"
class="text-error hover:text-error/80 transition-colors"
title="Delete rule">
<x-ri-delete-bin-line class="size-4" />
@@ -583,7 +685,7 @@
</div>
</td>
</tr>
@endforeach
</template>
</tbody>
</table>
</div>