Initial scaffold: full-stack RackMapper application
Complete project scaffold with working auth, REST API, Prisma/SQLite schema, Docker config, and React frontend for both Rack Planner and Service Mapper modules. Both server and client pass TypeScript strict mode with zero errors. Initial migration applied. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
267
client/src/components/modals/PortConfigModal.tsx
Normal file
267
client/src/components/modals/PortConfigModal.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Port, Vlan, VlanMode } from '../../types';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { useRackStore } from '../../store/useRackStore';
|
||||
|
||||
interface PortConfigModalProps {
|
||||
portId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) {
|
||||
const { racks, updateModuleLocal } = useRackStore();
|
||||
const [port, setPort] = useState<Port | null>(null);
|
||||
const [vlans, setVlans] = useState<Vlan[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [mode, setMode] = useState<VlanMode>('ACCESS');
|
||||
const [nativeVlanId, setNativeVlanId] = useState<string>('');
|
||||
const [taggedVlanIds, setTaggedVlanIds] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// Quick-create VLAN
|
||||
const [newVlanId, setNewVlanId] = useState('');
|
||||
const [newVlanName, setNewVlanName] = useState('');
|
||||
const [creatingVlan, setCreatingVlan] = useState(false);
|
||||
|
||||
// Find the port from store
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let found: Port | undefined;
|
||||
for (const rack of racks) {
|
||||
for (const mod of rack.modules) {
|
||||
found = mod.ports.find((p) => p.id === portId);
|
||||
if (found) break;
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
if (found) {
|
||||
setPort(found);
|
||||
setLabel(found.label ?? '');
|
||||
setMode(found.mode);
|
||||
setNativeVlanId(found.nativeVlan?.toString() ?? '');
|
||||
setTaggedVlanIds(found.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
|
||||
setNotes(found.notes ?? '');
|
||||
}
|
||||
}, [open, portId, racks]);
|
||||
|
||||
// Load VLAN list
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setFetching(true);
|
||||
apiClient.vlans
|
||||
.list()
|
||||
.then(setVlans)
|
||||
.catch(() => toast.error('Failed to load VLANs'))
|
||||
.finally(() => setFetching(false));
|
||||
}, [open]);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const vlanAssignments = [
|
||||
...(mode === 'ACCESS' && nativeVlanId
|
||||
? [{ vlanId: vlans.find((v) => v.vlanId === Number(nativeVlanId))?.id ?? '', tagged: false }]
|
||||
: []),
|
||||
...(mode !== 'ACCESS'
|
||||
? taggedVlanIds.map((id) => ({ vlanId: id, tagged: true }))
|
||||
: []),
|
||||
].filter((v) => v.vlanId);
|
||||
|
||||
await apiClient.ports.update(portId, {
|
||||
label: label.trim() || undefined,
|
||||
mode,
|
||||
nativeVlan: nativeVlanId ? Number(nativeVlanId) : null,
|
||||
notes: notes.trim() || undefined,
|
||||
vlans: vlanAssignments,
|
||||
});
|
||||
|
||||
// Refresh racks to reflect changes
|
||||
const portsInRack = racks.flatMap((r) => r.modules).find((m) => m.ports.some((p) => p.id === portId));
|
||||
if (portsInRack) {
|
||||
const updatedPorts = await apiClient.modules.getPorts(portsInRack.id);
|
||||
updateModuleLocal(portsInRack.id, { ports: updatedPorts });
|
||||
}
|
||||
|
||||
toast.success('Port saved');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateVlan() {
|
||||
const id = Number(newVlanId);
|
||||
if (!id || !newVlanName.trim()) return;
|
||||
setCreatingVlan(true);
|
||||
try {
|
||||
const created = await apiClient.vlans.create({ vlanId: id, name: newVlanName.trim() });
|
||||
setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId));
|
||||
setNewVlanId('');
|
||||
setNewVlanName('');
|
||||
toast.success(`VLAN ${id} created`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create VLAN');
|
||||
} finally {
|
||||
setCreatingVlan(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTaggedVlan(vlanDbId: string) {
|
||||
setTaggedVlanIds((prev) =>
|
||||
prev.includes(vlanDbId) ? prev.filter((id) => id !== vlanDbId) : [...prev, vlanDbId]
|
||||
);
|
||||
}
|
||||
|
||||
if (!port) return null;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Port info */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="slate">{port.portType}</Badge>
|
||||
<span className="text-xs text-slate-500">Port #{port.portNumber}</span>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Label</label>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="e.g. Server 1 uplink"
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Mode</label>
|
||||
<div className="flex gap-2">
|
||||
{(['ACCESS', 'TRUNK', 'HYBRID'] as VlanMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium border transition-colors ${
|
||||
mode === m
|
||||
? 'bg-blue-600 border-blue-500 text-white'
|
||||
: 'bg-slate-900 border-slate-600 text-slate-400 hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Native VLAN */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Native VLAN</label>
|
||||
<select
|
||||
value={nativeVlanId}
|
||||
onChange={(e) => setNativeVlanId(e.target.value)}
|
||||
disabled={loading || fetching}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="">— Untagged —</option>
|
||||
{vlans.map((v) => (
|
||||
<option key={v.id} value={v.vlanId.toString()}>
|
||||
VLAN {v.vlanId} — {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tagged VLANs — Trunk/Hybrid only */}
|
||||
{mode !== 'ACCESS' && (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Tagged VLANs</label>
|
||||
<div className="max-h-32 overflow-y-auto bg-slate-900 border border-slate-600 rounded-lg p-2 flex flex-wrap gap-1.5">
|
||||
{vlans.length === 0 && (
|
||||
<span className="text-xs text-slate-600">No VLANs defined yet</span>
|
||||
)}
|
||||
{vlans.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => toggleTaggedVlan(v.id)}
|
||||
className={`px-2 py-0.5 rounded text-xs border transition-colors ${
|
||||
taggedVlanIds.includes(v.id)
|
||||
? 'bg-blue-700 border-blue-500 text-white'
|
||||
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
{v.vlanId} {v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick-create VLAN */}
|
||||
<div className="border border-slate-700 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-slate-400">Quick-create VLAN</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={4094}
|
||||
value={newVlanId}
|
||||
onChange={(e) => setNewVlanId(e.target.value)}
|
||||
placeholder="VLAN ID"
|
||||
className="w-24 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
value={newVlanName}
|
||||
onChange={(e) => setNewVlanName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleCreateVlan}
|
||||
loading={creatingVlan}
|
||||
disabled={!newVlanId || !newVlanName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-slate-300">Notes</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
disabled={loading}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" type="submit" loading={loading}>
|
||||
Save Port
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user