Files
enterprise_digital-platform/NetworkView/frontend/src/pages/RackPage.tsx
T

308 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 { 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>
);
}