/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useState } from 'react'; import { LabTemplate, Device, TopologyLink, User } from '../types'; import TopologyPanel from './TopologyPanel'; import { Server, Plus, Edit3, Trash, User as UserIcon, MapPin, Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe, } from 'lucide-react'; interface LabTemplatesProps { labs: LabTemplate[]; devices: Device[]; currentUser: User; onAddLab: (lab: Omit) => void; onUpdateLab: (lab: LabTemplate) => void; onDeleteLab: (id: string) => void; onOpenDeviceDetails: (device: Device) => void; } export default function LabTemplates({ labs, devices, currentUser, onAddLab, onUpdateLab, onDeleteLab, onOpenDeviceDetails }: LabTemplatesProps) { const [selectedLab, setSelectedLab] = useState(labs[0] || null); const [isEditing, setIsEditing] = useState(false); const [formMode, setFormMode] = useState<'add' | 'edit'>('add'); // Topology custom links helper state const [tempLinks, setTempLinks] = useState([]); const [linkFrom, setLinkFrom] = useState(''); const [linkTo, setLinkTo] = useState(''); const [linkType, setLinkType] = useState('Trunk Uplink'); const [editingLinkIdx, setEditingLinkIdx] = useState(null); const [editingLinkLabel, setEditingLinkLabel] = useState(''); const [formData, setFormData] = useState<{ id?: string; name: string; description: string; contactPerson: string; location: string; deviceIds: string[]; semaphoreSetupTemplateId: string; semaphoreTeardownTemplateId: string; scope: 'global' | 'personal'; }>({ name: '', description: '', contactPerson: '', location: '', deviceIds: [], semaphoreSetupTemplateId: '', semaphoreTeardownTemplateId: '', scope: 'global', }); // Calculate filtered devices associated with selected lab const labDevices = selectedLab ? devices.filter(d => selectedLab.deviceIds.includes(d.id)) : []; const handleOpenAdd = () => { setFormMode('add'); setTempLinks([]); setFormData({ name: '', description: '', contactPerson: '', location: '', deviceIds: [], semaphoreSetupTemplateId: '', semaphoreTeardownTemplateId: '', scope: 'global', }); setIsEditing(true); }; const handleOpenEdit = (lab: LabTemplate) => { setFormMode('edit'); setTempLinks([...lab.topology]); setFormData({ id: lab.id, name: lab.name, description: lab.description, contactPerson: lab.contactPerson, location: lab.location, deviceIds: [...lab.deviceIds], semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '', scope: lab.scope ?? 'global', }); setIsEditing(true); }; // Toggle device association in form const handleToggleDevice = (devId: string) => { const isChosen = formData.deviceIds.includes(devId); let newDevices = []; if (isChosen) { newDevices = formData.deviceIds.filter(id => id !== devId); // Clean up invalid topology links referencing deleted devices setTempLinks(tempLinks.filter(l => l.fromDevice !== devId && l.toDevice !== devId)); } else { newDevices = [...formData.deviceIds, devId]; } setFormData({ ...formData, deviceIds: newDevices }); }; // Add path link to list const handleAddLink = () => { if (!linkFrom || !linkTo || linkFrom === linkTo) return; setTempLinks([...tempLinks, { fromDevice: linkFrom, toDevice: linkTo, type: linkType }]); setLinkFrom(''); setLinkTo(''); }; const handleRemoveLink = (idx: number) => { setTempLinks(tempLinks.filter((_, i) => i !== idx)); }; const handleSave = (e: React.FormEvent) => { e.preventDefault(); if (!formData.name.trim() || formData.deviceIds.length === 0) { alert('Please provide a descriptive template name and associate at least one hardware device node.'); return; } const savedLabData = { name: formData.name, description: formData.description, contactPerson: formData.contactPerson, location: formData.location, deviceIds: formData.deviceIds, topology: tempLinks, semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId, semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId, scope: formData.scope, }; if (formMode === 'add') { onAddLab(savedLabData); } else if (formMode === 'edit' && formData.id) { const existing = labs.find(l => l.id === formData.id); onUpdateLab({ ...savedLabData, id: formData.id, ownerId: existing?.ownerId ?? '', }); } setIsEditing(false); }; const isAdmin = currentUser.role?.toLowerCase() === 'admin'; const canEdit = (lab: LabTemplate) => isAdmin || lab.ownerId === currentUser.id || lab.ownerId === ''; const myPersonalLabs = labs.filter(l => l.scope === 'personal' && l.ownerId === currentUser.id); const globalLabs = labs.filter(l => l.scope === 'global'); const othersPersonal = isAdmin ? labs.filter(l => l.scope === 'personal' && l.ownerId !== currentUser.id) : []; const renderLabCard = (lab: LabTemplate) => { const isSelected = selectedLab?.id === lab.id; const editable = canEdit(lab); return (
setSelectedLab(lab)} className={`p-4 rounded-xl border transition-all cursor-pointer relative ${ isSelected ? 'bg-slate-900 border-emerald-500' : 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60' }`} >

{lab.name}

e.stopPropagation()}> {editable && ( <> )}

{lab.description}

{lab.contactPerson}
{lab.location}
{lab.deviceIds.length} connected devices {lab.scope === 'personal' ? ( Personal ) : ( Global )}
); }; return (
{/* LEFT COLUMN: Lab List */}

Topology

Predefined architectural scenarios & wiring profiles.

{/* Labs templates list — sectioned */}
{myPersonalLabs.length > 0 && ( <>

My Topologies

{myPersonalLabs.map(renderLabCard)} )} {globalLabs.length > 0 && ( <>

Global Topologies

{globalLabs.map(renderLabCard)} )} {othersPersonal.length > 0 && ( <>

Others' Personal

{othersPersonal.map(renderLabCard)} )} {labs.length === 0 && (

No topology templates yet.

)}
{/* RIGHT COLUMN: Active Lab Details, Devices details, and TOPOLOGY MAP */}
{selectedLab ? ( <> {/* Template Card Meta */}
TEMPLATE ID: {selectedLab.id.toUpperCase()}

{selectedLab.name}

Primary Contact

{selectedLab.contactPerson}

Testing Location

{selectedLab.location}

{selectedLab.description}

{/* Interactive Visual Topology */} {/* Sub-Devices components list */}

Associated Physical Hardware Map

{labDevices.map((device) => (
onOpenDeviceDetails(device)} className="p-3 bg-slate-900/40 border border-slate-800 hover:border-slate-700 hover:bg-slate-900 transition-colors rounded-lg cursor-pointer flex justify-between items-center" >

{device.hostname}

{device.ip}

{device.status}
))}
) : (
Select a lab scenario template from the left directory column to inspect active port topology connections.
)}
{/* FORM MODAL: Create or Edit Lab Template */} {isEditing && (

{formMode === 'add' ? 'Design New Lab Template' : 'Modify Lab Template Configuration'}

{/* Name & Location */}
setFormData({ ...formData, name: e.target.value })} className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500" />
setFormData({ ...formData, location: e.target.value })} className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500" />
{/* Description & Contact person */}
setFormData({ ...formData, contactPerson: e.target.value })} className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500" />
setFormData({ ...formData, description: e.target.value })} className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500" />
{/* Scope toggle */}
{/* Hardware checklist */}

Select the devices from the active hardware inventory associated with this testing group.

{devices.map((dev) => { const isChecked = formData.deviceIds.includes(dev.id); return ( ); })}
{/* Physical/Logical topology builder link creator */}

Model logical and physical patch cabling among the selected devices (e.g. Trunk aggregation profiles, LACP uplinks).

{/* Connection Inputs */}
setLinkType(e.target.value)} />
{/* Listing added links list */} {tempLinks.length > 0 ? (
{tempLinks.map((link, idx) => { const fromDev = devices.find(d => d.id === link.fromDevice)?.hostname || link.fromDevice; const toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice; const isEditingThis = editingLinkIdx === idx; return (
{fromDev} ──── {isEditingThis ? ( setEditingLinkLabel(e.target.value)} onBlur={() => { if (editingLinkLabel.trim()) { setTempLinks(tempLinks.map((l, i) => i === idx ? { ...l, type: editingLinkLabel.trim() } : l)); } setEditingLinkIdx(null); }} onKeyDown={(e) => { if (e.key === 'Enter') { if (editingLinkLabel.trim()) { setTempLinks(tempLinks.map((l, i) => i === idx ? { ...l, type: editingLinkLabel.trim() } : l)); } setEditingLinkIdx(null); } if (e.key === 'Escape') setEditingLinkIdx(null); }} className="flex-1 min-w-0 bg-slate-800 text-slate-100 border border-indigo-500 rounded px-1.5 py-0.5 focus:outline-none" /> ) : ( {link.type} )} ──── {toDev}
); })}
) : (

No interface connections formulated yet.

)}
{/* Ansible Semaphore Automation */}

Semaphore task template IDs to trigger at booking start and end. Leave empty to skip automation for this lab.

setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })} className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60" />
setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })} className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60" />
{/* Form submit handlers */}
)}
); }