Initial commit baby!
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, AlertCircle, RefreshCw, Pencil, Check, X, Network } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Vlan, SwitchPort } from '../../types';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface PortForm {
|
||||
tagged_ports: string;
|
||||
untagged_ports: string;
|
||||
}
|
||||
|
||||
export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number; onGoToPorts?: (bridgeName: string) => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: vlans = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['vlans', deviceId],
|
||||
queryFn: () => devicesApi.getVlans(deviceId).then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: portsData } = useQuery({
|
||||
queryKey: ['ports', deviceId],
|
||||
queryFn: () => devicesApi.getPorts(deviceId).then((r) => r.data),
|
||||
});
|
||||
const ports: SwitchPort[] = portsData?.ports ?? [];
|
||||
|
||||
// Find bridges where vlan-filtering is not enabled (stored as 'yes' from RouterOS or 'true' after toggle)
|
||||
const bridgesWithoutFiltering = ports.filter(
|
||||
(p) => p.type === 'bridge' && p.config_json?.['vlan-filtering'] !== 'yes' && p.config_json?.['vlan-filtering'] !== 'true'
|
||||
);
|
||||
|
||||
// ── Add form ──
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ vlan_id: '', bridge: '', tagged_ports: '', untagged_ports: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
|
||||
// ── Edit form ──
|
||||
const [editingVlan, setEditingVlan] = useState<Vlan | null>(null);
|
||||
const [editForm, setEditForm] = useState<PortForm>({ tagged_ports: '', untagged_ports: '' });
|
||||
const [editError, setEditError] = useState('');
|
||||
|
||||
// ── Delete error ──
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const bridgeOptions = [...new Set((vlans as Vlan[]).map((v) => v.bridge).filter(Boolean))];
|
||||
|
||||
// ── Mutations ──
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const tagged = addForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const untagged = addForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return devicesApi.addVlan(deviceId, {
|
||||
bridge: addForm.bridge,
|
||||
vlan_id: parseInt(addForm.vlan_id, 10),
|
||||
tagged_ports: tagged,
|
||||
untagged_ports: untagged,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
|
||||
setShowAdd(false);
|
||||
setAddError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setAddError(msg || 'Failed to add VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!editingVlan) throw new Error('No VLAN selected');
|
||||
const tagged = editForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const untagged = editForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return devicesApi.updateVlan(deviceId, editingVlan.id, {
|
||||
tagged_ports: tagged,
|
||||
untagged_ports: untagged,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setEditingVlan(null);
|
||||
setEditError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setEditError(msg || 'Failed to update VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (vlanDbId: number) => devicesApi.deleteVlan(deviceId, vlanDbId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setDeleteError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setDeleteError(msg || 'Failed to delete VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = (vlan: Vlan) => {
|
||||
setEditingVlan(vlan);
|
||||
setEditError('');
|
||||
setShowAdd(false);
|
||||
setEditForm({
|
||||
tagged_ports: (vlan.tagged_ports ?? []).join(', '),
|
||||
untagged_ports: (vlan.untagged_ports ?? []).join(', '),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* VLAN Filtering warning */}
|
||||
{bridgesWithoutFiltering.length > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 px-4 py-3">
|
||||
<Network className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
<span className="font-semibold">VLAN Filtering is not enabled</span> on the Bridge interface
|
||||
{bridgesWithoutFiltering.length > 1
|
||||
? ` (${bridgesWithoutFiltering.map((b) => b.name).join(', ')})`
|
||||
: ` (${bridgesWithoutFiltering[0].name})`}.
|
||||
{' '}VLAN functionality is disabled — tagging and untagging rules will not be enforced.{' '}
|
||||
{onGoToPorts && (
|
||||
<button
|
||||
onClick={() => onGoToPorts(bridgesWithoutFiltering[0].name)}
|
||||
className="inline-flex items-center gap-1 font-semibold underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 transition-colors"
|
||||
>
|
||||
Enable VLAN Filtering on the Bridge interface →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{deleteError && (
|
||||
<span className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />{deleteError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd(!showAdd);
|
||||
setEditingVlan(null);
|
||||
setAddError('');
|
||||
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add VLAN
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add VLAN form */}
|
||||
{canWrite && showAdd && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">New Bridge VLAN</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<label className="label">VLAN ID (1–4094)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={addForm.vlan_id}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, vlan_id: e.target.value }))}
|
||||
min={1} max={4094}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Bridge</label>
|
||||
{bridgeOptions.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={addForm.bridge}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
|
||||
>
|
||||
{bridgeOptions.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.bridge}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
|
||||
placeholder="bridge1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Tagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.tagged_ports}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, tagged_ports: e.target.value }))}
|
||||
placeholder="ether1, ether2"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Untagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.untagged_ports}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, untagged_ports: e.target.value }))}
|
||||
placeholder="ether3"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
|
||||
</div>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {addError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => addMutation.mutate()}
|
||||
disabled={!addForm.vlan_id || !addForm.bridge || addMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
Add VLAN
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit VLAN panel */}
|
||||
{canWrite && editingVlan && (
|
||||
<div className="card p-4 space-y-3 border-blue-300 dark:border-blue-600 border-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Edit VLAN{' '}
|
||||
<span className="font-mono text-blue-600 dark:text-blue-400">{editingVlan.vlan_id}</span>
|
||||
{editingVlan.bridge && (
|
||||
<span className="text-gray-400 dark:text-slate-500 font-normal"> on {editingVlan.bridge}</span>
|
||||
)}
|
||||
</h4>
|
||||
<button onClick={() => setEditingVlan(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">Tagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={editForm.tagged_ports}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, tagged_ports: e.target.value }))}
|
||||
placeholder="ether1, ether2"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Untagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={editForm.untagged_ports}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, untagged_ports: e.target.value }))}
|
||||
placeholder="ether3"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Check className="w-3.5 h-3.5" />}
|
||||
Save Changes
|
||||
</button>
|
||||
<button onClick={() => setEditingVlan(null)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VLANs table */}
|
||||
<div className="card overflow-hidden">
|
||||
{(vlans as Vlan[]).length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-slate-500">
|
||||
No VLANs configured on this device, or device not synced yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">VLAN ID</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Bridge</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Tagged Ports</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Untagged Ports</th>
|
||||
{canWrite && <th className="table-header px-4 py-2.5 w-16" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(vlans as Vlan[]).map((vlan) => (
|
||||
<tr
|
||||
key={vlan.id}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/30',
|
||||
editingVlan?.id === vlan.id && 'bg-blue-50 dark:bg-blue-900/10'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-mono font-bold text-blue-600 dark:text-blue-400">
|
||||
{vlan.vlan_id}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{vlan.name || `VLAN ${vlan.vlan_id}`}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">
|
||||
{vlan.bridge || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(vlan.tagged_ports ?? []).map((p) => (
|
||||
<span key={p} className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs rounded font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{!(vlan.tagged_ports?.length) && '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(vlan.untagged_ports ?? []).map((p) => (
|
||||
<span key={p} className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 text-xs rounded font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{!(vlan.untagged_ports?.length) && '—'}
|
||||
</div>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => openEdit(vlan)}
|
||||
className={clsx(
|
||||
'p-1 rounded transition-colors',
|
||||
editingVlan?.id === vlan.id
|
||||
? 'text-blue-500 bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
title="Edit VLAN"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove VLAN ${vlan.vlan_id} from ${vlan.bridge}?`)) {
|
||||
deleteMutation.mutate(vlan.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Delete VLAN"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user