Files
enterprise_digital-platform/NetworkView/frontend/src/pages/ComponentPage.tsx
T
ske087 0aefadbfd8 NetworkView: API routing fix, logout button, audit trail, port/notes editor tracking
- 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
2026-05-10 23:10:02 +03:00

678 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}