609 lines
28 KiB
TypeScript
609 lines
28 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|