Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user