Files
GhostGrid/src/components/DeviceInventory.tsx

609 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.

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { Device, DeviceType } from '../types';
import {
Server, Search, Plus, Trash, Edit2, MapPin, Gauge,
BookOpen, Save, X, Info, ExternalLink
} from 'lucide-react';
// Built-in device class presets shown in the dropdown.
const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'];
interface DeviceInventoryProps {
devices: Device[];
checkmkEnabled: boolean;
checkmkBaseUrl: string;
onAddDevice: (device: Omit<Device, 'id'>) => void;
onUpdateDevice: (device: Device) => void;
onDeleteDevice: (id: string) => void;
}
export default function DeviceInventory({
devices,
checkmkEnabled,
checkmkBaseUrl,
onAddDevice,
onUpdateDevice,
onDeleteDevice,
}: DeviceInventoryProps) {
// Filters & State
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(devices[0]?.id || null);
// Always derived from prop so edits reflect immediately in the detail panel
const selectedDevice = useMemo(
() => devices.find(d => d.id === selectedDeviceId) ?? null,
[devices, selectedDeviceId]
);
// Create / Edit modal state
const [isEditing, setIsEditing] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
// True when the user is defining a device class outside the presets.
const [isCustomType, setIsCustomType] = useState(false);
const [formData, setFormData] = useState<{
id?: string;
hostname: string;
ip: string;
location: string;
notes: string;
type: DeviceType;
emergencySheet: string;
}>({
hostname: '',
ip: '',
location: '',
notes: '',
type: 'Switch',
emergencySheet: '',
});
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
const cmkHostUrl = (d: Device) =>
checkmkEnabled && checkmkBaseUrl && d.cmkHostname
? `${checkmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
: null;
const statusMeta = (s: 'online' | 'offline' | 'unknown') => {
if (s === 'online') return { label: 'online', badge: 'bg-emerald-950/60 border-emerald-900/80 text-emerald-400', dot: 'bg-emerald-400' };
if (s === 'offline') return { label: 'offline', badge: 'bg-rose-950/60 border-rose-900/80 text-rose-400', dot: 'bg-rose-400' };
return { label: 'unknown', badge: 'bg-slate-800/60 border-slate-700 text-slate-400', dot: 'bg-slate-500' };
};
// Filtered devices list
const filteredDevices = devices.filter(dev => {
const matchesSearch =
dev.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
dev.ip.includes(searchTerm) ||
dev.location.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || dev.type === typeFilter;
return matchesSearch && matchesType;
});
const handleOpenAdd = () => {
setFormMode('add');
setIsCustomType(false);
setFormData({
hostname: '',
ip: '172.16.',
location: '',
notes: '',
type: 'Switch',
emergencySheet: `### EMERGENCY MANUAL [HOSTNAME]
**Device Type:** [Enter Model]
**Serial Number:** [Enter Serial Number]
#### 1. Out-of-Band Console Connection
* **Baud Rate:** 115200
* **Data Bits:** 8
* **Parity:** None
* **Stop Bits:** 1
#### 2. Recovery / Hard Reset
1. Press and hold down the physical reset micro-button on the front panel.
2. Cycle power, wait 10 seconds, then release.`
});
setIsEditing(true);
};
const handleOpenEdit = (dev: Device) => {
setFormMode('edit');
setIsCustomType(!DEVICE_CLASS_PRESETS.includes(dev.type));
setFormData({
id: dev.id,
hostname: dev.hostname,
ip: dev.ip,
location: dev.location,
notes: dev.notes,
type: dev.type,
emergencySheet: dev.emergencySheet
});
setIsEditing(true);
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.hostname.trim() || !formData.ip.trim() || !formData.type.trim()) return;
if (formMode === 'add') {
onAddDevice({
hostname: formData.hostname,
ip: formData.ip,
location: formData.location,
notes: formData.notes,
type: formData.type,
status: 'unknown',
emergencySheet: formData.emergencySheet,
});
} else if (formMode === 'edit' && formData.id) {
const match = devices.find(d => d.id === formData.id);
if (match) {
onUpdateDevice({
...match,
hostname: formData.hostname,
ip: formData.ip,
location: formData.location,
notes: formData.notes,
type: formData.type,
emergencySheet: formData.emergencySheet,
});
}
}
setIsEditing(false);
};
// Safe internal renderer for markdown headings and code blocks to support the emergency sheet visually
const renderEmergencySheetHtml = (text: string) => {
if (!text) return <p className="text-slate-400 italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
const lines = text.split('\n');
return lines.map((line, idx) => {
// Headers
if (line.startsWith('### ')) {
return <h4 key={idx} className="text-sm font-bold text-slate-100 mt-4 mb-2 border-b border-slate-800 pb-1 font-sans">{line.replace('### ', '')}</h4>;
}
if (line.startsWith('#### ')) {
return <h5 key={idx} className="text-xs font-bold text-emerald-400 mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
}
if (line.startsWith('**') && line.endsWith('**')) {
return <p key={idx} className="text-xs font-semibold text-slate-200 mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
}
// Bullet lists
if (line.startsWith('* ') || line.startsWith('- ')) {
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
<span className="text-emerald-500"></span>
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
</div>;
}
// Numeric lists
if (/^\d+\s*\.\s/.test(line)) {
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
<span className="text-emerald-400 font-bold">{line.match(/^\d+/)?.[0]}.</span>
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
</div>;
}
// Codeblocks
if (line.startsWith('`') && line.endsWith('`')) {
return <code key={idx} className="block bg-slate-950 p-2 rounded text-[10px] font-mono text-emerald-300 my-2 border border-slate-900 overflow-x-auto">{line.replace(/\`/g, '')}</code>;
}
if (line.trim() === '```bash' || line.trim() === '```') {
return null;
}
// Inline formatting fallback
if (line.includes('**')) {
return (
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('**').map((tok, ti) => {
return ti % 2 === 1 ? <strong key={ti} className="text-slate-100">{tok}</strong> : tok;
})}
</p>
);
}
if (line.includes('`')) {
return (
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('`').map((tok, ti) => {
return ti % 2 === 1 ? <code key={ti} className="bg-slate-950 px-1 py-0.5 rounded text-[10px] text-emerald-300 font-mono">{tok}</code> : tok;
})}
</p>
);
}
return line.trim() === '' ? <div key={idx} className="h-2" /> : <p key={idx} className="text-xs text-slate-300 my-0.5 font-sans">{line}</p>;
});
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
{/* LEFT COLUMN: Device List & Controls */}
<div className="lg:col-span-7 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="inventory-list-container">
{/* Title */}
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2 font-sans">
<Server className="w-5 h-5 text-emerald-400" />
Inventory
</h2>
<p className="text-xs text-slate-400 font-sans">Every switch, firewall and AP we own. the source of truth, until someone forgets to update it.</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleOpenAdd}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-sans font-semibold rounded-lg text-xs transition-colors hover:cursor-pointer"
id="btn-add-device"
>
<Plus className="w-4 h-4 text-slate-950 stroke-[3]" />
Add Device
</button>
</div>
</div>
{/* Filter Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-800 rounded-lg">
<div className="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-400">
<Search className="w-4 h-4" />
</span>
<input
type="text"
placeholder="Search by hostname, IP address, rack location..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-500 transition-colors placeholder:text-slate-500"
/>
</div>
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
{['all', 'Switch', 'Firewall', 'Access-Point', 'Controller'].map((type) => (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === type
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : type}
</button>
))}
</div>
</div>
{/* Device Listing Card Table */}
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
{filteredDevices.length === 0 ? (
<div className="text-center py-12 text-slate-500 text-xs font-sans">
grep came back empty. no boxes match that filter.
</div>
) : (
filteredDevices.map((device) => {
const isSelected = selectedDevice?.id === device.id;
return (
<div
key={device.id}
onClick={() => setSelectedDeviceId(device.id)}
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
isSelected
? 'bg-slate-900 border-emerald-500/80 shadow-[0_4px_12px_rgba(16,185,129,0.06)]'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex items-start gap-3.5">
{/* Device Icon Circle */}
<div className={`p-2 rounded-lg border text-base ${
device.type === 'Firewall' ? 'bg-rose-950/20 border-rose-900/60 text-rose-400' :
device.type === 'Access-Point' ? 'bg-amber-950/20 border-amber-900/60 text-amber-400' :
device.type === 'Controller' ? 'bg-cyan-950/20 border-cyan-900/60 text-cyan-400' :
'bg-teal-950/20 border-teal-900/60 text-teal-400'
}`}>
<Server className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-white text-sm">{device.hostname}</span>
<span className="text-[10px] px-2 py-0.5 font-sans rounded-full font-medium bg-slate-800 border border-slate-700 text-slate-400">{device.type}</span>
</div>
<div className="flex flex-col gap-0.5 mt-1 font-sans">
<span className="text-xs font-mono text-emerald-400">{device.ip}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-1">
<MapPin className="w-3 h-3 text-slate-500" />
{device.location}
</span>
</div>
</div>
</div>
{/* Right: Actions and Status */}
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
{/* CheckMK Status Badge only when CheckMK is enabled */}
{checkmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
<div className="flex flex-col items-end gap-1 font-sans">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
</div>
); })()}
{/* Action Panel */}
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
{cmkHostUrl(device) && (
<a
href={cmkHostUrl(device)!}
target="_blank"
rel="noopener noreferrer"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Open host in CheckMK"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
<button
onClick={() => handleOpenEdit(device)}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
title="Edit specifications"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to permanently delete device node "${device.hostname}" from the inventory? Existing topology mappings will become invalid.`)) {
onDeleteDevice(device.id);
}
}}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-rose-400 transition-colors"
title="Delete device"
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* RIGHT COLUMN: Notfallhandbuch & Technical Specs Details */}
<div className="lg:col-span-5 flex flex-col gap-6" id="inventory-details-container">
{selectedDevice ? (
<>
{/* Header Spec Block */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
<span className="text-[10px] font-mono uppercase bg-slate-900 border border-slate-800 px-2.5 py-0.5 rounded text-amber-400 font-semibold">
SPECS ID: {selectedDevice.id.toUpperCase()}
</span>
<h3 className="text-lg font-bold text-slate-100 mt-2 font-mono flex items-center justify-between">
<span>{selectedDevice.hostname}</span>
<span className="text-xs font-sans text-slate-400 font-normal">Active Link State</span>
</h3>
<p className="text-xs text-slate-400 font-mono mt-0.5 bg-slate-950 p-2.5 rounded border border-slate-850 mt-2 leading-relaxed">
Hostname: <span className="text-slate-200">{selectedDevice.hostname}</span><br />
IP Address: <span className="text-emerald-400 font-bold">{selectedDevice.ip}</span><br />
Location: <span className="text-slate-200">{selectedDevice.location}</span><br />
Node Class: <span className="text-slate-200">{selectedDevice.type}</span>
</p>
<div className="mt-4 font-sans">
<h4 className="text-xs font-semibold text-slate-300">Description & Technical Notes:</h4>
<div className="mt-1 bg-slate-900/50 rounded p-2.5 border border-slate-800/80 text-xs text-slate-300 leading-relaxed">
{selectedDevice.notes || 'No description notes registered.'}
</div>
</div>
{/* CheckMK Monitoring Panel only when CheckMK is enabled */}
{checkmkEnabled && (
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
<Gauge className="w-3.5 h-3.5 text-cyan-400" />
CheckMK Monitoring
</span>
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
); })()}
</div>
{cmkHostUrl(selectedDevice) && (
<a
href={cmkHostUrl(selectedDevice)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-slate-900 border border-slate-700 text-slate-200 hover:text-cyan-400 hover:border-cyan-500 rounded text-xs transition-colors font-mono"
>
<ExternalLink className="w-3.5 h-3.5" />
Open host in CheckMK
</a>
)}
{selectedDevice.lastCheckedAt && (
<p className="text-[10px] text-slate-500 font-mono">
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
</p>
)}
</div>
)}
</div>
{/* Emergency rescue guidelines sheet */}
<div className="bg-[#1D2432] border border-amber-900/40 rounded-xl p-5 shadow-sm overflow-hidden relative">
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-amber-500 to-rose-500" />
<div className="flex items-center justify-between border-b border-amber-900/30 pb-3 mb-4">
<div className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-amber-500" />
<h3 className="font-bold text-sm text-slate-100 font-sans">
Emergency Sheet & Disaster Recovery
</h3>
</div>
<span className="text-[9px] font-mono font-bold bg-amber-950 text-amber-300 px-2 py-0.5 rounded border border-amber-900/50">
RESCUE SHEET
</span>
</div>
{/* Markdown Content box */}
<div className="max-h-[350px] overflow-y-auto bg-slate-950/80 p-4 rounded-lg border border-slate-900 leading-relaxed font-sans scrollbar-thin">
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
</div>
<div className="mt-4 flex items-center gap-2 text-[10px] text-slate-400 bg-slate-900/60 p-2.5 rounded border border-slate-800">
<Info className="w-4 h-4 text-amber-400 shrink-0" />
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
</div>
</div>
</>
) : (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-10 text-center text-slate-500 text-xs font-sans">
Pick a box from the list to see its specs and break-glass playbook.
</div>
)}
</div>
{/* FORM MODAL: Add / Edit Equipment */}
{isEditing && (
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#1E293B] border border-slate-700 w-full max-w-lg rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="bg-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Server className="w-4 h-4 text-emerald-400" />
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
</h3>
<button
onClick={() => setIsEditing(false)}
className="text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Hostname</label>
<input
type="text"
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
placeholder="SW-CORE-03"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">IP Address</label>
<input
type="text"
required
value={formData.ip}
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
placeholder="172.16.x.x"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
<input
type="text"
required
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Server Room R02, Rack C4..."
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Device Class</label>
<select
value={isCustomType ? '__custom__' : formData.type}
onChange={(e) => {
if (e.target.value === '__custom__') {
setIsCustomType(true);
setFormData({ ...formData, type: '' });
} else {
setIsCustomType(false);
setFormData({ ...formData, type: e.target.value as DeviceType });
}
}}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
>
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
<option value="Firewall">Firewall / Security Appliance</option>
<option value="Access-Point">Access-Point (WLAN Node)</option>
<option value="Controller">Wireless Controller (WLC Engine)</option>
<option value="__custom__">+ Define new class</option>
</select>
{isCustomType && (
<input
type="text"
required
autoFocus
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
className="w-full mt-2 bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Enter new device class (e.g. Router, Load-Balancer)"
/>
)}
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Technical Notes / Patching Mappings</label>
<textarea
rows={2}
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Serial numbers, module slots, connected uplinks, license status..."
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
<textarea
rows={6}
value={formData.emergencySheet}
onChange={(e) => setFormData({ ...formData, emergencySheet: e.target.value })}
className="w-full bg-slate-950 text-slate-250 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono text-[11px] leading-tight"
placeholder="### EMERGENCY DETAILS..."
/>
</div>
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center gap-1.5"
>
<Save className="w-3.5 h-3.5 text-slate-950" />
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}