0aefadbfd8
- Fix frontend API base path (VITE_API_BASE env var, GraphPage hardcoded /api) - Add logout button to NetworkView sidebar (clears portal SSO) - Add AuditTrail component: inline change history on all entity pages - DB migration: add updated_at, last_edited_by to ports table - DB migration: add notes_last_edited_by, notes_updated_at to all entity tables - Backend: track actor on port create/update; notes editor on entity PUT - Frontend: extend types, MarkdownEditor shows last editor, port modal/list show last editor - Fix port CREATE TABLE definition to include new columns upfront - Add try/catch in handleSavePort to surface API errors in modal
678 lines
28 KiB
TypeScript
678 lines
28 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||
import * as api from '../api';
|
||
import type { Component, Port } from '../types';
|
||
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
|
||
import MarkdownEditor from '../components/MarkdownEditor';
|
||
import AuditTrail from '../components/AuditTrail';
|
||
|
||
const STATUS_OPTIONS = ['active', 'maintenance', 'decommissioned'] as const;
|
||
|
||
export default function ComponentPage() {
|
||
const { componentId } = useParams<{ componentId: string }>();
|
||
const navigate = useNavigate();
|
||
const [component, setComponent] = useState<Component | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [editing, setEditing] = useState(false);
|
||
const [notes, setNotes] = useState('');
|
||
const [form, setForm] = useState<Partial<Component>>({});
|
||
const [saving, setSaving] = useState(false);
|
||
type PortModalState = { editPort: Port | null };
|
||
const BLANK_PORT_FORM = { port_number: '', label: '', port_type: 'RJ45', notes: '', connected_to_port_id: '' };
|
||
const [portModal, setPortModal] = useState<PortModalState | null>(null);
|
||
const [portForm, setPortForm] = useState(BLANK_PORT_FORM);
|
||
const [patchPanels, setPatchPanels] = useState<Component[]>([]);
|
||
const [switches, setSwitches] = useState<Component[]>([]);
|
||
const [swLink, setSwLink] = useState<{ switchId: string; portNumber: string; portId: string | null } | null>(null);
|
||
const [portError, setPortError] = useState<string | null>(null);
|
||
|
||
const loadComponent = useCallback(async () => {
|
||
if (!componentId) return;
|
||
const c = await api.getComponent(componentId);
|
||
setComponent(c);
|
||
setNotes(c.notes ?? '');
|
||
setForm({
|
||
name: c.name, type: c.type, position: c.position, height_units: c.height_units,
|
||
manufacturer: c.manufacturer, model: c.model, serial_number: c.serial_number,
|
||
asset_tag: c.asset_tag, ip_address: c.ip_address, mac_address: c.mac_address, status: c.status,
|
||
port_count: c.port_count,
|
||
sfp_count: c.sfp_count,
|
||
});
|
||
setLoading(false);
|
||
if (c.type === 'switch' && c.rack?.id) {
|
||
const all = await api.getComponents(c.rack.id);
|
||
setPatchPanels(all.filter(comp => comp.type === 'patch_panel'));
|
||
}
|
||
if (c.type === 'patch_panel' && c.rack?.id) {
|
||
const all = await api.getComponents(c.rack.id);
|
||
setSwitches(all.filter(comp => comp.type === 'switch'));
|
||
}
|
||
}, [componentId]);
|
||
|
||
useEffect(() => { loadComponent(); }, [loadComponent]);
|
||
|
||
const saveNotes = async (val: string) => {
|
||
if (!componentId) return;
|
||
await api.updateComponent(componentId, { notes: val });
|
||
};
|
||
|
||
const saveMeta = async () => {
|
||
if (!componentId) return;
|
||
setSaving(true);
|
||
try {
|
||
await api.updateComponent(componentId, form);
|
||
await loadComponent();
|
||
setEditing(false);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!componentId || !component || !confirm(`Delete component "${component.name}"?`)) return;
|
||
await api.deleteComponent(componentId);
|
||
navigate(component.rack ? `/racks/${component.rack.id}` : '/');
|
||
};
|
||
|
||
const handleDeletePort = async (portId: string) => {
|
||
if (!componentId || !confirm('Delete this port?')) return;
|
||
await api.deletePort(componentId, portId);
|
||
await loadComponent();
|
||
};
|
||
|
||
const openPortModal = (port?: Port) => {
|
||
setPortError(null);
|
||
setPortModal({ editPort: port ?? null });
|
||
setPortForm(port ? {
|
||
port_number: String(port.port_number),
|
||
label: port.label ?? '',
|
||
port_type: port.port_type,
|
||
notes: port.notes ?? '',
|
||
connected_to_port_id: port.connected_to_port_id ?? '',
|
||
} : BLANK_PORT_FORM);
|
||
if (port?.linked_port?.component_type === 'switch') {
|
||
setSwLink({ switchId: port.linked_port.component_id, portNumber: String(port.linked_port.port_number), portId: port.linked_port.id });
|
||
} else {
|
||
setSwLink(null);
|
||
}
|
||
};
|
||
const closePortModal = () => { setPortModal(null); setSwLink(null); setPortError(null); };
|
||
|
||
const handleSavePort = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!componentId || !portForm.port_number) return;
|
||
setPortError(null);
|
||
|
||
// Patch-panel only: validate no duplicate port number and capacity limit
|
||
if (component?.type === 'patch_panel' && !portModal?.editPort) {
|
||
const existingPorts: Port[] = component.ports ?? [];
|
||
const portNum = Number(portForm.port_number);
|
||
const maxPorts = component.port_count ?? Infinity;
|
||
|
||
if (existingPorts.some(p => p.port_number === portNum)) {
|
||
setPortError(`Port ${portNum} is already added. Each port number can only appear once.`);
|
||
return;
|
||
}
|
||
if (existingPorts.length >= maxPorts) {
|
||
setPortError(`This patch panel is full (${maxPorts} ports). Delete an existing port to add a new one.`);
|
||
return;
|
||
}
|
||
if (portNum < 1 || portNum > maxPorts) {
|
||
setPortError(`Port number must be between 1 and ${maxPorts}.`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Resolve connected_to_port_id for patch panels via swLink
|
||
let connectedToPortId: string | null | undefined = portForm.connected_to_port_id || null;
|
||
if (component?.type === 'patch_panel') {
|
||
if (swLink?.switchId && swLink?.portNumber) {
|
||
if (swLink.portId) {
|
||
connectedToPortId = swLink.portId;
|
||
} else {
|
||
// Switch port record doesn't exist yet – create it first
|
||
const newSwPort = await api.createPort(swLink.switchId, {
|
||
port_number: Number(swLink.portNumber),
|
||
port_type: 'RJ45',
|
||
});
|
||
connectedToPortId = newSwPort.id;
|
||
}
|
||
} else {
|
||
connectedToPortId = null;
|
||
}
|
||
}
|
||
|
||
try {
|
||
if (portModal?.editPort) {
|
||
await api.updatePort(componentId, portModal.editPort.id, {
|
||
label: portForm.label || undefined,
|
||
port_type: portForm.port_type,
|
||
notes: portForm.notes || undefined,
|
||
connected_to_port_id: connectedToPortId,
|
||
});
|
||
} else {
|
||
await api.createPort(componentId, {
|
||
port_number: Number(portForm.port_number),
|
||
label: portForm.label || undefined,
|
||
port_type: portForm.port_type,
|
||
notes: portForm.notes || undefined,
|
||
connected_to_port_id: connectedToPortId ?? undefined,
|
||
});
|
||
}
|
||
} catch (err: unknown) {
|
||
setPortError(err instanceof Error ? err.message : 'Failed to save port');
|
||
return;
|
||
}
|
||
setPortModal(null);
|
||
setSwLink(null);
|
||
setPortError(null);
|
||
await loadComponent();
|
||
};
|
||
|
||
const setField = (key: keyof Component, val: unknown) =>
|
||
setForm(f => ({ ...f, [key]: val }));
|
||
|
||
if (loading || !component) return <div className="page-loading">Loading…</div>;
|
||
|
||
const meta = COMPONENT_META[component.type];
|
||
const ports: Port[] = component.ports ?? [];
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-header">
|
||
<div className="breadcrumb">
|
||
<Link to="/">Dashboard</Link>
|
||
{component.site && <><span> / </span><Link to={`/sites/${component.site.id}`}>{component.site.name}</Link></>}
|
||
{component.room && <><span> / </span><Link to={`/rooms/${component.room.id}`}>{component.room.name}</Link></>}
|
||
{component.rack && <><span> / </span><Link to={`/racks/${component.rack.id}`}>{component.rack.name}</Link></>}
|
||
<span> / </span>
|
||
<span>{component.name}</span>
|
||
</div>
|
||
<div className="page-actions">
|
||
{component.rack && (
|
||
<button className="btn-back btn-sm" onClick={() => navigate(`/racks/${component.rack!.id}`)}>← Back to Rack</button>
|
||
)}
|
||
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
|
||
{editing ? 'Cancel' : '✎ Edit'}
|
||
</button>
|
||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Component Header */}
|
||
<div className="component-header" style={{ borderLeft: `6px solid ${meta.color}` }}>
|
||
<div className="component-type-badge" style={{ background: meta.bg, color: meta.color }}>
|
||
{meta.label}
|
||
</div>
|
||
<h1 className="entity-title">{component.name}</h1>
|
||
<span className={`status-badge status-${component.status}`}>{component.status}</span>
|
||
</div>
|
||
|
||
{/* Meta fields */}
|
||
<section className="content-section">
|
||
{editing ? (
|
||
<div className="edit-meta-form">
|
||
<div className="form-row">
|
||
<div className="form-group form-group-lg">
|
||
<label>Name</label>
|
||
<input className="form-input" value={form.name ?? ''} onChange={e => setField('name', e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Type</label>
|
||
<select className="form-input" value={form.type ?? 'other'} onChange={e => setField('type', e.target.value)}>
|
||
{COMPONENT_TYPES.map(t => <option key={t} value={t}>{COMPONENT_META[t].label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Status</label>
|
||
<select className="form-input" value={form.status ?? 'active'} onChange={e => setField('status', e.target.value)}>
|
||
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>Position (U)</label>
|
||
<input type="number" className="form-input" value={form.position ?? ''} onChange={e => setField('position', e.target.value ? Number(e.target.value) : null)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Height (U)</label>
|
||
<input type="number" className="form-input" value={form.height_units ?? 1} onChange={e => setField('height_units', Number(e.target.value))} />
|
||
</div>
|
||
{(form.type === 'switch' || form.type === 'patch_panel') && (
|
||
<div className="form-group">
|
||
<label>{form.type === 'patch_panel' ? 'Available Patches' : 'Switch Ports'}</label>
|
||
<input
|
||
type="number" min={1} max={96} className="form-input"
|
||
value={form.port_count ?? 24}
|
||
onChange={e => setField('port_count', Number(e.target.value))}
|
||
/>
|
||
<span className="form-hint">
|
||
{form.type === 'patch_panel' ? 'RJ45 jacks on front panel' : 'Ethernet port count'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{form.type === 'switch' && (
|
||
<div className="form-group">
|
||
<label>Fiber / SFP Ports</label>
|
||
<input
|
||
type="number" min={0} max={16} className="form-input"
|
||
value={form.sfp_count ?? 0}
|
||
onChange={e => setField('sfp_count', Number(e.target.value))}
|
||
/>
|
||
<span className="form-hint">SFP / fiber uplink slots</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>Manufacturer</label>
|
||
<input className="form-input" value={form.manufacturer ?? ''} onChange={e => setField('manufacturer', e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Model</label>
|
||
<input className="form-input" value={form.model ?? ''} onChange={e => setField('model', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>IP Address</label>
|
||
<input className="form-input" value={form.ip_address ?? ''} onChange={e => setField('ip_address', e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>MAC Address</label>
|
||
<input className="form-input" value={form.mac_address ?? ''} onChange={e => setField('mac_address', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>Serial Number</label>
|
||
<input className="form-input" value={form.serial_number ?? ''} onChange={e => setField('serial_number', e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Asset Tag</label>
|
||
<input className="form-input" value={form.asset_tag ?? ''} onChange={e => setField('asset_tag', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button className="btn-primary btn-sm" onClick={saveMeta} disabled={saving}>
|
||
{saving ? 'Saving…' : 'Save Changes'}
|
||
</button>
|
||
<button className="btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="component-meta-grid">
|
||
<MetaField label="Type" value={meta.label} />
|
||
<MetaField label="Status" value={component.status} />
|
||
<MetaField label="Rack Position" value={component.position != null ? `${component.position}U` : '—'} />
|
||
<MetaField label="Height" value={`${component.height_units}U`} />
|
||
<MetaField label="Manufacturer" value={component.manufacturer} />
|
||
<MetaField label="Model" value={component.model} />
|
||
<MetaField label="IP Address" value={component.ip_address} />
|
||
<MetaField label="MAC Address" value={component.mac_address} />
|
||
<MetaField label="Serial Number" value={component.serial_number} />
|
||
<MetaField label="Asset Tag" value={component.asset_tag} />
|
||
{(component.type === 'switch' || component.type === 'patch_panel') && (
|
||
<MetaField
|
||
label={component.type === 'patch_panel' ? 'Available Patches' : 'Port Count'}
|
||
value={component.port_count != null ? `${component.port_count} ports` : null}
|
||
/>
|
||
)}
|
||
{component.type === 'switch' && (
|
||
<MetaField
|
||
label="Fiber / SFP Ports"
|
||
value={component.sfp_count != null ? `${component.sfp_count} SFP slots` : null}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Ports */}
|
||
<section className="content-section">
|
||
<div className="section-header">
|
||
<h2 className="section-title">Ports / Connections</h2>
|
||
<button className="btn-primary btn-sm" onClick={() => openPortModal()}>
|
||
+ {component.type === 'patch_panel' ? 'Add PP Port' : 'Add SW Port'}
|
||
</button>
|
||
</div>
|
||
|
||
{ports.length === 0 ? (
|
||
<div className="empty-state-sm">No ports documented yet.</div>
|
||
) : (
|
||
<div className="ports-grid">
|
||
{ports.map(port => (
|
||
<div key={port.id} className="port-item">
|
||
<div className="port-number">{port.port_number}</div>
|
||
<div className="port-info">
|
||
<div className="port-label">{port.label || '—'}</div>
|
||
{component.type !== 'patch_panel' && (
|
||
<div className="port-type">{port.port_type}</div>
|
||
)}
|
||
{port.notes && <div className="port-notes">{port.notes}</div>}
|
||
{port.linked_port && (
|
||
<div className="port-link-chain">
|
||
{component.type === 'switch'
|
||
? `↔ ${port.linked_port.component_name} : P${port.linked_port.port_number}${port.linked_port.label ? ` → ${port.linked_port.label}` : ''}`
|
||
: `↔ ${port.linked_port.component_name} : P${port.linked_port.port_number}`
|
||
}
|
||
</div>
|
||
)}
|
||
{port.last_edited_by && (
|
||
<div className="port-edited-by">
|
||
✎ {port.last_edited_by}
|
||
{port.updated_at && (
|
||
<> · {new Date(port.updated_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button className="port-edit-btn" onClick={() => openPortModal(port)} title="Edit port">✎</button>
|
||
<button className="port-delete" onClick={() => handleDeletePort(port.id)} title="Delete port">✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Port Modal */}
|
||
{portModal !== null && (
|
||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && closePortModal()}>
|
||
<div className="modal">
|
||
<div className="modal-header">
|
||
<span className="modal-title">
|
||
{portModal.editPort
|
||
? `Edit ${component.type === 'patch_panel' ? 'PP' : 'SW'} Port ${portModal.editPort.port_number}`
|
||
: `Add ${component.type === 'patch_panel' ? 'PP Port' : 'SW Port'}`}
|
||
</span>
|
||
{portModal.editPort?.last_edited_by && (
|
||
<span className="modal-last-edited">
|
||
✎ {portModal.editPort.last_edited_by}
|
||
{portModal.editPort.updated_at && (
|
||
<> · {new Date(portModal.editPort.updated_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</>
|
||
)}
|
||
</span>
|
||
)}
|
||
<button className="modal-close" onClick={closePortModal}>✕</button>
|
||
</div>
|
||
<form onSubmit={handleSavePort} style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>{component.type === 'patch_panel' ? 'PP Port #' : 'SW Port #'}</label>
|
||
<input
|
||
type="number" className="form-input"
|
||
min={1}
|
||
max={component.type === 'patch_panel' && component.port_count ? component.port_count : undefined}
|
||
value={portForm.port_number}
|
||
onChange={e => setPortForm(f => ({ ...f, port_number: e.target.value }))}
|
||
required
|
||
disabled={!!portModal.editPort}
|
||
/>
|
||
{component.type === 'patch_panel' && component.port_count && !portModal.editPort && (
|
||
<span className="form-hint">Port 1–{component.port_count}</span>
|
||
)}
|
||
</div>
|
||
<div className="form-group form-group-lg">
|
||
<label>{component.type === 'patch_panel' ? 'End Device' : 'Label'}</label>
|
||
<input
|
||
className="form-input"
|
||
value={portForm.label}
|
||
onChange={e => setPortForm(f => ({ ...f, label: e.target.value }))}
|
||
placeholder={component.type === 'patch_panel' ? 'e.g. Desktop 1, Printer, IP Camera' : 'e.g. Uplink-01'}
|
||
autoFocus={!portModal.editPort}
|
||
/>
|
||
</div>
|
||
{component.type !== 'patch_panel' && (
|
||
<div className="form-group">
|
||
<label>Type</label>
|
||
<select className="form-input" value={portForm.port_type}
|
||
onChange={e => setPortForm(f => ({ ...f, port_type: e.target.value }))}>
|
||
{['RJ45', 'SFP', 'SFP+', 'QSFP', 'LC', 'SC', 'Serial', 'USB', 'HDMI'].map(t =>
|
||
<option key={t} value={t}>{t}</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div className="form-group">
|
||
<label>Notes</label>
|
||
<input
|
||
className="form-input"
|
||
value={portForm.notes}
|
||
onChange={e => setPortForm(f => ({ ...f, notes: e.target.value }))}
|
||
placeholder={component.type === 'patch_panel' ? 'e.g. Room A, Floor 2' : 'e.g. VLAN 10'}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{component.type === 'switch' && patchPanels.length > 0 && (
|
||
<PpLinkPicker
|
||
key={portModal.editPort?.id ?? 'add'}
|
||
patchPanels={patchPanels}
|
||
initialPpId={
|
||
portModal.editPort?.linked_port?.component_type === 'patch_panel'
|
||
? portModal.editPort.linked_port.component_id
|
||
: undefined
|
||
}
|
||
value={portForm.connected_to_port_id}
|
||
onChange={id => setPortForm(f => ({ ...f, connected_to_port_id: id }))}
|
||
/>
|
||
)}
|
||
{component.type === 'patch_panel' && (
|
||
<SwLinkPicker
|
||
key={portModal.editPort?.id ?? 'add'}
|
||
switches={switches}
|
||
initialSwitchId={swLink?.switchId}
|
||
initialPortNumber={swLink?.portNumber}
|
||
currentLinkedPortId={swLink?.portId}
|
||
onChange={setSwLink}
|
||
/>
|
||
)}
|
||
{portError && (
|
||
<div style={{ color: '#f87171', fontSize: 13, background: '#450a0a', border: '1px solid #7f1d1d', borderRadius: 6, padding: '8px 12px' }}>
|
||
⚠ {portError}
|
||
</div>
|
||
)}
|
||
<div className="form-actions">
|
||
<button type="submit" className="btn-primary btn-sm">
|
||
{portModal.editPort ? 'Save Changes' : (component.type === 'patch_panel' ? 'Add PP Port' : 'Add SW Port')}
|
||
</button>
|
||
<button type="button" className="btn-secondary btn-sm" onClick={closePortModal}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Notes */}
|
||
<section className="content-section">
|
||
<h2 className="section-title">Documentation</h2>
|
||
<MarkdownEditor
|
||
value={notes}
|
||
onChange={setNotes}
|
||
onSave={saveNotes}
|
||
lastEditedBy={component.notes_last_edited_by}
|
||
lastEditedAt={component.notes_updated_at}
|
||
/>
|
||
</section>
|
||
|
||
<AuditTrail entityId={component.id} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetaField({ label, value }: { label: string; value?: string | number | null }) {
|
||
if (!value && value !== 0) return null;
|
||
return (
|
||
<div className="meta-field">
|
||
<span className="meta-field-label">{label}</span>
|
||
<span className="meta-field-value">{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PpLinkPicker({
|
||
patchPanels,
|
||
value,
|
||
initialPpId,
|
||
onChange,
|
||
}: {
|
||
patchPanels: Component[];
|
||
value: string;
|
||
initialPpId?: string;
|
||
onChange: (portId: string) => void;
|
||
}) {
|
||
const [selectedPpId, setSelectedPpId] = useState(initialPpId ?? '');
|
||
const [ppPorts, setPpPorts] = useState<Port[]>([]);
|
||
const [loadingPp, setLoadingPp] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!selectedPpId) { setPpPorts([]); return; }
|
||
setLoadingPp(true);
|
||
api.getComponent(selectedPpId).then(pp => {
|
||
setPpPorts(pp.ports ?? []);
|
||
setLoadingPp(false);
|
||
});
|
||
}, [selectedPpId]);
|
||
|
||
const selectedPort = ppPorts.find(p => p.id === value);
|
||
|
||
return (
|
||
<div className="pp-link-picker">
|
||
<span className="pp-link-label">🔗 Link to Patch Panel</span>
|
||
<div className="pp-link-row">
|
||
<select
|
||
className="form-input form-input-sm"
|
||
value={selectedPpId}
|
||
onChange={e => { setSelectedPpId(e.target.value); onChange(''); }}
|
||
>
|
||
<option value="">— no patch panel —</option>
|
||
{patchPanels.map(pp => (
|
||
<option key={pp.id} value={pp.id}>{pp.name}</option>
|
||
))}
|
||
</select>
|
||
{selectedPpId && (
|
||
<select
|
||
className="form-input form-input-sm"
|
||
value={value}
|
||
onChange={e => onChange(e.target.value)}
|
||
disabled={loadingPp}
|
||
>
|
||
<option value="">— select port —</option>
|
||
{ppPorts.map(p => (
|
||
<option key={p.id} value={p.id}>
|
||
Port {p.port_number}{p.label ? ` — ${p.label}` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
{selectedPort?.label && (
|
||
<div className="pp-link-device-hint">
|
||
↳ End device: <strong>{selectedPort.label}</strong>
|
||
</div>
|
||
)}
|
||
{value && (
|
||
<button
|
||
type="button"
|
||
className="pp-link-clear"
|
||
onClick={() => { onChange(''); setSelectedPpId(''); }}
|
||
>
|
||
✕ Clear link
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SwLinkPicker({
|
||
switches,
|
||
initialSwitchId,
|
||
initialPortNumber,
|
||
currentLinkedPortId,
|
||
onChange,
|
||
}: {
|
||
switches: Component[];
|
||
initialSwitchId?: string;
|
||
initialPortNumber?: string;
|
||
currentLinkedPortId?: string | null;
|
||
onChange: (link: { switchId: string; portNumber: string; portId: string | null } | null) => void;
|
||
}) {
|
||
const [selectedSwitchId, setSelectedSwitchId] = useState(initialSwitchId ?? '');
|
||
const [switchData, setSwitchData] = useState<Component | null>(null);
|
||
const [loadingSwitch, setLoadingSwitch] = useState(false);
|
||
const [selectedPortNumber, setSelectedPortNumber] = useState(initialPortNumber ?? '');
|
||
|
||
useEffect(() => {
|
||
if (!selectedSwitchId) { setSwitchData(null); return; }
|
||
setLoadingSwitch(true);
|
||
api.getComponent(selectedSwitchId).then(sw => {
|
||
setSwitchData(sw);
|
||
setLoadingSwitch(false);
|
||
});
|
||
}, [selectedSwitchId]);
|
||
|
||
const portCount = switchData?.port_count ?? 24;
|
||
|
||
const handleSwitchChange = (swId: string) => {
|
||
setSelectedSwitchId(swId);
|
||
setSelectedPortNumber('');
|
||
onChange(null);
|
||
};
|
||
|
||
const handlePortChange = (portNumberStr: string) => {
|
||
setSelectedPortNumber(portNumberStr);
|
||
if (!portNumberStr || !selectedSwitchId) { onChange(null); return; }
|
||
const existingPort = (switchData?.ports ?? []).find(p => p.port_number === Number(portNumberStr));
|
||
onChange({ switchId: selectedSwitchId, portNumber: portNumberStr, portId: existingPort?.id ?? null });
|
||
};
|
||
|
||
return (
|
||
<div className="pp-link-picker">
|
||
<span className="pp-link-label">🔗 Link to Switch Port <span style={{ color: 'var(--text3)', fontWeight: 400 }}>(optional)</span></span>
|
||
<div className="pp-link-row">
|
||
<select
|
||
className="form-input form-input-sm"
|
||
value={selectedSwitchId}
|
||
onChange={e => handleSwitchChange(e.target.value)}
|
||
>
|
||
<option value="">— no switch —</option>
|
||
{switches.map(sw => (
|
||
<option key={sw.id} value={sw.id}>{sw.name}</option>
|
||
))}
|
||
</select>
|
||
{selectedSwitchId && !loadingSwitch && switchData && (
|
||
<select
|
||
className="form-input form-input-sm"
|
||
value={selectedPortNumber}
|
||
onChange={e => handlePortChange(e.target.value)}
|
||
>
|
||
<option value="">— select port —</option>
|
||
{Array.from({ length: portCount }, (_, i) => i + 1).map(n => {
|
||
const existingPort = (switchData.ports ?? []).find(p => p.port_number === n);
|
||
const isOccupied = existingPort?.connected_to_port_id && existingPort.id !== currentLinkedPortId;
|
||
return (
|
||
<option key={n} value={String(n)} disabled={!!isOccupied}>
|
||
Port {n}{existingPort?.label ? ` — ${existingPort.label}` : ''}{isOccupied ? ' (in use)' : ''}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
)}
|
||
{loadingSwitch && <span style={{ color: 'var(--text3)', fontSize: 12 }}>Loading…</span>}
|
||
{switches.length === 0 && (
|
||
<span style={{ color: 'var(--text3)', fontSize: 12 }}>No switches in this rack</span>
|
||
)}
|
||
</div>
|
||
{selectedSwitchId && selectedPortNumber && (
|
||
<button
|
||
type="button"
|
||
className="pp-link-clear"
|
||
onClick={() => { setSelectedSwitchId(''); setSelectedPortNumber(''); onChange(null); }}
|
||
>
|
||
✕ Clear link
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|