630 lines
29 KiB
TypeScript
630 lines
29 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, Info,
|
|
BookOpen, Save, X, ExternalLink, Gauge
|
|
} 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[];
|
|
onAddDevice: (device: Omit<Device, 'id'>) => void;
|
|
onUpdateDevice: (device: Device) => void;
|
|
onDeleteDevice: (id: string) => void;
|
|
}
|
|
|
|
export default function DeviceInventory({
|
|
devices,
|
|
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;
|
|
checkMkUrl: string;
|
|
}>({
|
|
hostname: '',
|
|
ip: '',
|
|
location: '',
|
|
notes: '',
|
|
type: 'Switch',
|
|
emergencySheet: '',
|
|
checkMkUrl: ''
|
|
});
|
|
|
|
// Effective status: nothing is known until CheckMK is linked and reports a state.
|
|
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' =>
|
|
d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
|
|
|
|
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',
|
|
checkMkUrl: '',
|
|
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,
|
|
checkMkUrl: dev.checkMkUrl ?? '',
|
|
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,
|
|
checkMkUrl: formData.checkMkUrl
|
|
});
|
|
} 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,
|
|
checkMkUrl: formData.checkMkUrl
|
|
});
|
|
}
|
|
}
|
|
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 Monitoring Badge */}
|
|
{(() => { 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>
|
|
<span className="text-[9px] font-mono text-slate-500">{device.checkMkUrl ? 'via CheckMK' : 'not linked'}</span>
|
|
</div>
|
|
); })()}
|
|
|
|
{/* Action Panel */}
|
|
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
|
|
{device.checkMkUrl && (
|
|
<a
|
|
href={device.checkMkUrl}
|
|
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 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 */}
|
|
<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>
|
|
{selectedDevice.checkMkUrl ? (
|
|
<a
|
|
href={selectedDevice.checkMkUrl}
|
|
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>
|
|
) : (
|
|
<p className="text-[11px] text-slate-500 italic bg-slate-900/50 border border-slate-800 rounded p-2 leading-relaxed">
|
|
No CheckMK host linked yet. Add the host URL in the modify window - live status will be pulled from the CheckMK API.
|
|
</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>
|
|
|
|
{/* CheckMK Monitoring integration */}
|
|
<div className="bg-slate-900/60 border border-cyan-900/40 rounded-lg p-3.5 space-y-3">
|
|
<div className="flex items-center gap-2 text-cyan-400 font-semibold">
|
|
<Gauge className="w-4 h-4" />
|
|
CheckMK Monitoring
|
|
</div>
|
|
<p className="text-[11px] text-slate-400 leading-relaxed flex gap-1.5">
|
|
<Info className="w-3.5 h-3.5 text-cyan-400 shrink-0 mt-0.5" />
|
|
Link this host to CheckMK. Connectivity is monitored there and pulled in via the CheckMK API - no in-app ping checks.
|
|
</p>
|
|
<div>
|
|
<label className="block text-slate-300 font-semibold mb-1">CheckMK Host URL</label>
|
|
<input
|
|
type="text"
|
|
value={formData.checkMkUrl}
|
|
onChange={(e) => setFormData({ ...formData, checkMkUrl: e.target.value })}
|
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-cyan-500 font-mono"
|
|
placeholder="https://checkmk.internal/site/check_mk/index.py?host=SW-CORE-03"
|
|
/>
|
|
<p className="text-[10px] text-slate-500 mt-1.5 italic">Without a linked CheckMK host the status stays “unknown”.</p>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|