230 lines
9.3 KiB
TypeScript
230 lines
9.3 KiB
TypeScript
import { useCallback } from 'react'
|
|
import { Trash2 } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import type { DeviceProperties, DiagramEdge } from '@/types'
|
|
import type { Node, Edge } from '@xyflow/react'
|
|
import type { DeviceNodeData } from '../nodes/DeviceNode'
|
|
|
|
interface PropertiesPanelProps {
|
|
selectedNode: Node | null
|
|
selectedEdge: Edge | null
|
|
onNodeUpdate: (nodeId: string, data: Partial<DeviceNodeData>) => void
|
|
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => void
|
|
onDeleteNode: (nodeId: string) => void
|
|
onDeleteEdge: (edgeId: string) => void
|
|
}
|
|
|
|
const STATUS_OPTIONS = ['unknown', 'online', 'offline', 'degraded'] as const
|
|
const CONNECTION_TYPE_OPTIONS = ['ethernet', 'fiber', 'wifi', 'vpn', 'vlan', 'wan'] as const
|
|
|
|
function FieldLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{children}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function FieldInput({ value, onChange, placeholder, mono }: {
|
|
value: string
|
|
onChange: (val: string) => void
|
|
placeholder?: string
|
|
mono?: boolean
|
|
}) {
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className={cn(
|
|
'w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none',
|
|
mono && 'font-mono',
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function PropertiesPanel({
|
|
selectedNode,
|
|
selectedEdge,
|
|
onNodeUpdate,
|
|
onEdgeUpdate,
|
|
onDeleteNode,
|
|
onDeleteEdge,
|
|
}: PropertiesPanelProps) {
|
|
const handlePropertyChange = useCallback((field: keyof DeviceProperties, value: string) => {
|
|
if (!selectedNode) return
|
|
const nodeData = selectedNode.data as unknown as DeviceNodeData
|
|
onNodeUpdate(selectedNode.id, {
|
|
properties: { ...nodeData.properties, [field]: value },
|
|
} as Partial<DeviceNodeData>)
|
|
}, [selectedNode, onNodeUpdate])
|
|
|
|
const handleLabelChange = useCallback((value: string) => {
|
|
if (!selectedNode) return
|
|
onNodeUpdate(selectedNode.id, { label: value } as Partial<DeviceNodeData>)
|
|
}, [selectedNode, onNodeUpdate])
|
|
|
|
if (!selectedNode && !selectedEdge) {
|
|
return (
|
|
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-4">
|
|
<p className="text-center text-xs text-muted-foreground">
|
|
Select a device or connection to edit its properties
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (selectedEdge) {
|
|
const edgeData = (selectedEdge.data || {}) as Record<string, unknown>
|
|
const connectionType = (edgeData.connectionType as string) || 'ethernet'
|
|
const isCustomType = !CONNECTION_TYPE_OPTIONS.includes(connectionType as typeof CONNECTION_TYPE_OPTIONS[number])
|
|
|
|
return (
|
|
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
|
|
<div className="border-b border-default px-3 py-2">
|
|
<h3 className="text-xs font-semibold text-heading">Connection</h3>
|
|
</div>
|
|
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Label</FieldLabel>
|
|
<FieldInput
|
|
value={(selectedEdge.label as string) || ''}
|
|
onChange={val => onEdgeUpdate(selectedEdge.id, { label: val || null })}
|
|
placeholder="Connection label"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Connection Type</FieldLabel>
|
|
<select
|
|
value={isCustomType ? '__custom__' : connectionType}
|
|
onChange={e => {
|
|
const val = e.target.value
|
|
if (val !== '__custom__') {
|
|
onEdgeUpdate(selectedEdge.id, { connectionType: val })
|
|
}
|
|
}}
|
|
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
|
|
>
|
|
{CONNECTION_TYPE_OPTIONS.map(opt => (
|
|
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
|
))}
|
|
<option value="__custom__">Custom...</option>
|
|
</select>
|
|
{isCustomType && (
|
|
<FieldInput
|
|
value={connectionType}
|
|
onChange={val => onEdgeUpdate(selectedEdge.id, { connectionType: val })}
|
|
placeholder="Custom type name"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Speed</FieldLabel>
|
|
<FieldInput
|
|
value={(edgeData.speed as string) || ''}
|
|
onChange={val => onEdgeUpdate(selectedEdge.id, { speed: val || null })}
|
|
placeholder="e.g. 1 Gbps"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Notes</FieldLabel>
|
|
<FieldInput
|
|
value={(edgeData.notes as string) || ''}
|
|
onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
|
|
placeholder="Port info, cable type..."
|
|
mono
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-default p-3">
|
|
<button
|
|
onClick={() => onDeleteEdge(selectedEdge.id)}
|
|
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
|
|
>
|
|
<Trash2 size={12} />
|
|
Delete Connection
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const nodeData = selectedNode!.data as unknown as DeviceNodeData
|
|
const props = nodeData.properties || {} as DeviceProperties
|
|
|
|
return (
|
|
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
|
|
<div className="border-b border-default px-3 py-2">
|
|
<h3 className="text-xs font-semibold text-heading">Device Properties</h3>
|
|
</div>
|
|
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Label</FieldLabel>
|
|
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Hostname</FieldLabel>
|
|
<FieldInput value={props.hostname || ''} onChange={v => handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>IP Address</FieldLabel>
|
|
<FieldInput value={props.ip || ''} onChange={v => handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Subnet</FieldLabel>
|
|
<FieldInput value={props.subnet || ''} onChange={v => handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Vendor</FieldLabel>
|
|
<FieldInput value={props.vendor || ''} onChange={v => handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Model</FieldLabel>
|
|
<FieldInput value={props.model || ''} onChange={v => handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Role</FieldLabel>
|
|
<FieldInput value={props.role || ''} onChange={v => handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>VLAN</FieldLabel>
|
|
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Status</FieldLabel>
|
|
<select
|
|
value={props.status || 'unknown'}
|
|
onChange={e => handlePropertyChange('status', e.target.value)}
|
|
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
|
|
>
|
|
{STATUS_OPTIONS.map(opt => (
|
|
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Notes</FieldLabel>
|
|
<textarea
|
|
value={props.notes || ''}
|
|
onChange={e => handlePropertyChange('notes', e.target.value)}
|
|
placeholder="Additional notes..."
|
|
rows={3}
|
|
className="w-full resize-none rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-default p-3">
|
|
<button
|
|
onClick={() => onDeleteNode(selectedNode!.id)}
|
|
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
|
|
>
|
|
<Trash2 size={12} />
|
|
Delete Device
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|