308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||
import * as api from '../api';
|
||
import type { Rack, Component } from '../types';
|
||
import { COMPONENT_META } from '../types';
|
||
import RackGraphicView from '../components/RackGraphicView';
|
||
import MarkdownEditor from '../components/MarkdownEditor';
|
||
import { AddComponentModal, SimpleCreateModal } from '../components/AddItemModal';
|
||
import type { ComponentFormData } from '../components/AddItemModal';
|
||
|
||
// ── Rack Layout Table ─────────────────────────────────────────────────────────
|
||
function RackLayoutView({ rack, components, onAddAtSlot }: {
|
||
rack: Rack;
|
||
components: Component[];
|
||
onAddAtSlot?: (position: number) => void;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
|
||
// Map each unit number → component (for multi-U components, repeated)
|
||
const byPos = new Map<number, Component>();
|
||
for (const c of components) {
|
||
if (c.position != null) {
|
||
for (let u = c.position; u < c.position + c.height_units; u++) {
|
||
byPos.set(u, c);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build rows for the table
|
||
interface RowInfo { unit: number; comp?: Component; isFirst: boolean; span: number }
|
||
const rows: RowInfo[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
for (let u = 1; u <= rack.total_units; u++) {
|
||
const comp = byPos.get(u);
|
||
if (comp) {
|
||
if (!seen.has(comp.id)) {
|
||
seen.add(comp.id);
|
||
rows.push({ unit: u, comp, isFirst: true, span: comp.height_units });
|
||
} else {
|
||
rows.push({ unit: u, comp, isFirst: false, span: 0 });
|
||
}
|
||
} else {
|
||
rows.push({ unit: u, comp: undefined, isFirst: true, span: 1 });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="rack-layout">
|
||
<table className="rack-layout-table">
|
||
<tbody>
|
||
{rows.map(row => (
|
||
<tr key={row.unit} className="rack-layout-row">
|
||
<td className="rack-layout-u">{row.unit}</td>
|
||
{row.isFirst && (
|
||
row.comp ? (
|
||
<td
|
||
rowSpan={row.span}
|
||
className="rack-layout-comp"
|
||
style={{
|
||
background: COMPONENT_META[row.comp.type].bg,
|
||
borderLeft: `4px solid ${COMPONENT_META[row.comp.type].color}`,
|
||
}}
|
||
onClick={() => navigate(`/components/${row.comp!.id}`)}
|
||
>
|
||
<span className="rack-layout-comp-name">{row.comp.name}</span>
|
||
<span className="rack-layout-comp-type" style={{ color: COMPONENT_META[row.comp.type].color }}>
|
||
{COMPONENT_META[row.comp.type].label}
|
||
</span>
|
||
{row.comp.model && <span className="rack-layout-comp-model">{row.comp.model}</span>}
|
||
{row.comp.ip_address && <span className="rack-layout-comp-ip">{row.comp.ip_address}</span>}
|
||
{row.span > 1 && <span className="rack-layout-comp-height">{row.span}U</span>}
|
||
</td>
|
||
) : (
|
||
<td
|
||
className="rack-layout-empty"
|
||
onClick={() => onAddAtSlot?.(row.unit)}
|
||
>
|
||
<span className="rack-layout-empty-txt">· empty · click to add</span>
|
||
</td>
|
||
)
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function RackPage() {
|
||
const { rackId } = useParams<{ rackId: string }>();
|
||
const navigate = useNavigate();
|
||
const [rack, setRack] = useState<Rack | null>(null);
|
||
const [components, setComponents] = useState<Component[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [notes, setNotes] = useState('');
|
||
const [showAddComponent, setShowAddComponent] = useState(false);
|
||
const [addAtPosition, setAddAtPosition] = useState<number | undefined>(undefined);
|
||
const [showEditRack, setShowEditRack] = useState(false);
|
||
const [compView, setCompView] = useState<'table' | 'layout'>('table');
|
||
|
||
const loadRack = useCallback(async () => {
|
||
if (!rackId) return;
|
||
const data = await api.getRack(rackId);
|
||
setRack(data);
|
||
setComponents(data.components ?? []);
|
||
setNotes(data.notes ?? '');
|
||
setLoading(false);
|
||
}, [rackId]);
|
||
|
||
useEffect(() => { loadRack(); }, [loadRack]);
|
||
|
||
const saveNotes = async (val: string) => {
|
||
if (!rackId) return;
|
||
await api.updateRack(rackId, { notes: val });
|
||
};
|
||
|
||
const handleAddComponent = async (formData: ComponentFormData) => {
|
||
await api.createComponent(formData);
|
||
setShowAddComponent(false);
|
||
await loadRack();
|
||
};
|
||
|
||
const handleAddAtSlot = (position: number) => {
|
||
setAddAtPosition(position);
|
||
setShowAddComponent(true);
|
||
};
|
||
|
||
const handleEditRack = async (data: Record<string, string | number>) => {
|
||
if (!rackId) return;
|
||
await api.updateRack(rackId, {
|
||
name: data.name as string,
|
||
total_units: Number(data.total_units),
|
||
manufacturer: data.manufacturer as string,
|
||
model: data.model as string,
|
||
});
|
||
setShowEditRack(false);
|
||
await loadRack();
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!rackId || !rack || !confirm(`Delete rack "${rack.name}" and all its components?`)) return;
|
||
await api.deleteRack(rackId);
|
||
navigate(rack.room ? `/rooms/${rack.room.id}` : '/');
|
||
};
|
||
|
||
if (loading || !rack) return <div className="page-loading">Loading…</div>;
|
||
|
||
const usedUnits = components.reduce((s, c) => s + (c.position != null ? c.height_units : 0), 0);
|
||
const freeUnits = rack.total_units - usedUnits;
|
||
|
||
return (
|
||
<div className="page page-rack">
|
||
<div className="page-header">
|
||
<div className="breadcrumb">
|
||
<Link to="/">Dashboard</Link>
|
||
{rack.site && <><span> / </span><Link to={`/sites/${rack.site.id}`}>{rack.site.name}</Link></>}
|
||
{rack.room && <><span> / </span><Link to={`/rooms/${rack.room.id}`}>{rack.room.name}</Link></>}
|
||
<span> / </span>
|
||
<span>{rack.name}</span>
|
||
</div>
|
||
<div className="page-actions">
|
||
{rack.room && (
|
||
<button className="btn-back btn-sm" onClick={() => navigate(`/rooms/${rack.room!.id}`)}>← Back to Room</button>
|
||
)}
|
||
<button className="btn-graph btn-sm" onClick={() => navigate(`/graph/rack/${rackId}`)}>⬡ Graph View</button>
|
||
<button className="btn-secondary btn-sm" onClick={() => setShowEditRack(true)}>✎ Edit</button>
|
||
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
|
||
+ Add Component
|
||
</button>
|
||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="entity-header">
|
||
<h1 className="entity-title">
|
||
<span className="entity-icon">🗄️</span> {rack.name}
|
||
</h1>
|
||
<div className="entity-meta-row">
|
||
<span className="entity-meta-chip">{rack.total_units}U total</span>
|
||
<span className="entity-meta-chip" style={{ color: '#f59e0b' }}>{usedUnits}U used</span>
|
||
<span className="entity-meta-chip" style={{ color: '#34d399' }}>{freeUnits}U free</span>
|
||
{rack.manufacturer && <span className="entity-meta-chip">{rack.manufacturer}</span>}
|
||
{rack.model && <span className="entity-meta-chip">{rack.model}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Two-column rack layout */}
|
||
<div className="rack-page-columns">
|
||
{/* LEFT: Component management */}
|
||
<div className="rack-page-left">
|
||
<section className="content-section">
|
||
<div className="section-header">
|
||
<h2 className="section-title">Components</h2>
|
||
<div className="section-view-toggle">
|
||
<button
|
||
className={`btn-sm ${compView === 'table' ? 'btn-secondary' : 'btn-ghost'}`}
|
||
title="Table view"
|
||
onClick={() => setCompView('table')}
|
||
>⊞ Table</button>
|
||
<button
|
||
className={`btn-sm ${compView === 'layout' ? 'btn-secondary' : 'btn-ghost'}`}
|
||
title="Rack layout view"
|
||
onClick={() => setCompView('layout')}
|
||
>▦ Rack Map</button>
|
||
</div>
|
||
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
|
||
+ Add Component
|
||
</button>
|
||
</div>
|
||
{compView === 'layout' ? (
|
||
components.length === 0 ? (
|
||
<div className="empty-state"><p>No components yet.</p></div>
|
||
) : (
|
||
<RackLayoutView rack={rack} components={components} onAddAtSlot={handleAddAtSlot} />
|
||
)
|
||
) : (
|
||
components.length === 0 ? (
|
||
<div className="empty-state">
|
||
<p>No components yet.</p>
|
||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>
|
||
Click <strong>+ Add Component</strong> or click an empty slot in the rack diagram →
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<table className="component-table">
|
||
<thead>
|
||
<tr>
|
||
<th>U</th>
|
||
<th>Name</th>
|
||
<th>Type</th>
|
||
<th>Model</th>
|
||
<th>IP</th>
|
||
<th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[...components]
|
||
.sort((a, b) => (a.position ?? 9999) - (b.position ?? 9999))
|
||
.map(c => {
|
||
const meta = COMPONENT_META[c.type];
|
||
return (
|
||
<tr key={c.id} className="component-table-row" onClick={() => navigate(`/components/${c.id}`)} style={{ borderLeft: `3px solid ${meta.color}` }}>
|
||
<td className="td-position">{c.position ?? '—'}</td>
|
||
<td className="td-name">{c.name}</td>
|
||
<td className="td-type" style={{ color: meta.color }}>{meta.label}</td>
|
||
<td className="td-model">{c.model ?? '—'}</td>
|
||
<td className="td-ip">{c.ip_address ?? '—'}</td>
|
||
<td className="td-status"><span className={`status-badge status-${c.status}`}>{c.status}</span></td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)
|
||
)}
|
||
</section>
|
||
|
||
<section className="content-section">
|
||
<h2 className="section-title">Rack Notes</h2>
|
||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||
</section>
|
||
</div>
|
||
|
||
{/* RIGHT: Graphical front panel */}
|
||
<div className="rack-page-right">
|
||
<div className="rack-page-right-scroll">
|
||
<div className="rack-page-right-header">
|
||
<span className="rack-page-right-title">Front Panel View</span>
|
||
<span className="rack-page-right-hint">Click slot to add · Click device to open</span>
|
||
</div>
|
||
<RackGraphicView
|
||
rack={rack}
|
||
components={components}
|
||
onAddAtSlot={handleAddAtSlot}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{showAddComponent && rackId && (
|
||
<AddComponentModal
|
||
rackId={rackId}
|
||
totalUnits={rack.total_units}
|
||
initialPosition={addAtPosition}
|
||
onSave={handleAddComponent}
|
||
onClose={() => setShowAddComponent(false)}
|
||
/>
|
||
)}
|
||
|
||
{showEditRack && (
|
||
<SimpleCreateModal
|
||
title="Edit Rack"
|
||
fields={[
|
||
{ key: 'name', label: 'Rack Name', defaultValue: rack.name },
|
||
{ key: 'total_units', label: 'Size (U)', type: 'number', defaultValue: rack.total_units },
|
||
{ key: 'manufacturer', label: 'Manufacturer', defaultValue: rack.manufacturer ?? '' },
|
||
{ key: 'model', label: 'Model', defaultValue: rack.model ?? '' },
|
||
]}
|
||
onSave={handleEditRack}
|
||
onClose={() => setShowEditRack(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|