498 lines
20 KiB
TypeScript
498 lines
20 KiB
TypeScript
import { useCallback, useState, useEffect } from 'react'
|
|
import {
|
|
Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack,
|
|
AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
|
|
AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
|
|
AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
|
|
} 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
|
|
onEdgeTypeChange: (edgeId: string, edgeType: string) => void
|
|
onBringToFront: (nodeId: string) => void
|
|
onSendToBack: (nodeId: string) => void
|
|
onDeleteNode: (nodeId: string) => void
|
|
onDeleteEdge: (edgeId: string) => void
|
|
selectedNodeCount: number
|
|
onAlignLeft: () => void
|
|
onAlignRight: () => void
|
|
onAlignCenterH: () => void
|
|
onAlignTop: () => void
|
|
onAlignBottom: () => void
|
|
onAlignCenterV: () => void
|
|
onDistributeH: () => void
|
|
onDistributeV: () => void
|
|
canAlign: boolean
|
|
canDistribute: boolean
|
|
}
|
|
|
|
type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
|
|
|
|
const STATUS_CONFIG: Record<NodeStatus, { color: string; label: string }> = {
|
|
online: { color: '#34d399', label: 'Online' },
|
|
offline: { color: '#f87171', label: 'Offline' },
|
|
degraded: { color: '#fbbf24', label: 'Degraded' },
|
|
unknown: { color: '#94a3b8', label: 'Unknown' },
|
|
}
|
|
|
|
const STATUS_OPTIONS = Object.keys(STATUS_CONFIG) as NodeStatus[]
|
|
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',
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SectionDivider({ label }: { label: string }) {
|
|
return (
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<span className="whitespace-nowrap text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{label}
|
|
</span>
|
|
<div className="flex-1 border-t border-default" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function PropertiesPanel({
|
|
selectedNode,
|
|
selectedEdge,
|
|
onNodeUpdate,
|
|
onEdgeUpdate,
|
|
onEdgeTypeChange,
|
|
onBringToFront,
|
|
onSendToBack,
|
|
onDeleteNode,
|
|
onDeleteEdge,
|
|
selectedNodeCount,
|
|
onAlignLeft,
|
|
onAlignRight,
|
|
onAlignCenterH,
|
|
onAlignTop,
|
|
onAlignBottom,
|
|
onAlignCenterV,
|
|
onDistributeH,
|
|
onDistributeV,
|
|
canAlign,
|
|
canDistribute,
|
|
}: PropertiesPanelProps) {
|
|
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
|
|
|
// Reset confirm state whenever the selection changes
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
useEffect(() => { setDeleteConfirm(false) }, [selectedNode?.id, selectedEdge?.id])
|
|
|
|
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 && selectedNodeCount >= 2) {
|
|
return (
|
|
<div className="w-[260px] border-l border-default bg-sidebar flex flex-col">
|
|
<div className="px-4 py-3 border-b border-default">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
{selectedNodeCount} nodes selected
|
|
</div>
|
|
</div>
|
|
<div className="p-3 flex flex-col gap-4">
|
|
{canAlign && (
|
|
<div>
|
|
<div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Align</div>
|
|
<div className="grid grid-cols-3 gap-1">
|
|
{([
|
|
{ label: 'Left', icon: AlignStartVertical, action: onAlignLeft },
|
|
{ label: 'Center', icon: AlignCenterHorizontal, action: onAlignCenterH },
|
|
{ label: 'Right', icon: AlignEndVertical, action: onAlignRight },
|
|
{ label: 'Top', icon: AlignStartHorizontal, action: onAlignTop },
|
|
{ label: 'Middle', icon: AlignCenterVertical, action: onAlignCenterV },
|
|
{ label: 'Bottom', icon: AlignEndHorizontal, action: onAlignBottom },
|
|
] as const).map(({ label, icon: Icon, action }) => (
|
|
<button
|
|
key={label}
|
|
onClick={action}
|
|
title={`Align ${label}`}
|
|
className="flex flex-col items-center gap-1 p-2 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary"
|
|
>
|
|
<Icon size={14} />
|
|
<span className="text-[9px]">{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{canDistribute && (
|
|
<div>
|
|
<div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Distribute</div>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<button
|
|
onClick={onDistributeH}
|
|
className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
|
|
>
|
|
<AlignHorizontalSpaceAround size={13} /> Horizontal
|
|
</button>
|
|
<button
|
|
onClick={onDistributeV}
|
|
className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
|
|
>
|
|
<AlignVerticalSpaceAround size={13} /> Vertical
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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>
|
|
<p className="mt-1 text-center text-[10px] text-muted-foreground/60">
|
|
Hover a device to preview its info
|
|
</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>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 className="flex flex-col gap-1">
|
|
<FieldLabel>Line Style</FieldLabel>
|
|
<div className="flex gap-1">
|
|
{([
|
|
{ value: null, icon: Minus, label: 'Straight' },
|
|
{ value: 'curved', icon: Spline, label: 'Curved' },
|
|
{ value: 'step', icon: GitBranch, label: 'Step' },
|
|
] as const).map(({ value, icon: Icon, label }) => {
|
|
const routing = (edgeData.routing as string | null | undefined) ?? null
|
|
const active = routing === value
|
|
return (
|
|
<button
|
|
key={label}
|
|
title={label}
|
|
onClick={() => onEdgeUpdate(selectedEdge.id, { routing: value })}
|
|
className={cn(
|
|
'flex flex-1 items-center justify-center gap-1 rounded border py-1.5 text-[10px] transition-colors',
|
|
active
|
|
? 'border-accent bg-accent/10 text-accent'
|
|
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
|
|
)}
|
|
>
|
|
<Icon size={12} />
|
|
{label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<FieldLabel>Show Traffic</FieldLabel>
|
|
<button
|
|
onClick={() => {
|
|
const newType = selectedEdge.type === 'animated' ? 'connection' : 'animated'
|
|
onEdgeTypeChange(selectedEdge.id, newType)
|
|
}}
|
|
className={cn(
|
|
'relative h-5 w-9 rounded-full transition-colors',
|
|
selectedEdge.type === 'animated' ? 'bg-accent' : 'bg-elevated',
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform',
|
|
selectedEdge.type === 'animated' && 'translate-x-4',
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-default p-3">
|
|
{deleteConfirm ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
<p className="text-center text-[10px] text-muted-foreground">Delete this connection?</p>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
onClick={() => setDeleteConfirm(false)}
|
|
className="flex-1 rounded border border-default px-2 py-1.5 text-xs text-primary hover:bg-elevated"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => onDeleteEdge(selectedEdge.id)}
|
|
className="flex-1 rounded bg-red-500/20 px-2 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setDeleteConfirm(true)}
|
|
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
|
|
const currentStatus = (props.status || 'unknown') as NodeStatus
|
|
|
|
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">
|
|
|
|
{/* Identity */}
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Name</FieldLabel>
|
|
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
|
</div>
|
|
|
|
{/* Layering */}
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel>Layer</FieldLabel>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
onClick={() => onBringToFront(selectedNode!.id)}
|
|
title="Bring to Front ]"
|
|
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
|
|
>
|
|
<BringToFront size={12} />
|
|
Bring Front
|
|
</button>
|
|
<button
|
|
onClick={() => onSendToBack(selectedNode!.id)}
|
|
title="Send to Back ["
|
|
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
|
|
>
|
|
<SendToBack size={12} />
|
|
Send Back
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status badge grid */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<FieldLabel>Status</FieldLabel>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{STATUS_OPTIONS.map(opt => {
|
|
const { color, label } = STATUS_CONFIG[opt]
|
|
const active = currentStatus === opt
|
|
return (
|
|
<button
|
|
key={opt}
|
|
onClick={() => handlePropertyChange('status', opt)}
|
|
className={cn(
|
|
'flex items-center justify-center gap-1.5 rounded border py-1.5 text-[10px] font-medium transition-colors',
|
|
active
|
|
? 'border-transparent text-white'
|
|
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
|
|
)}
|
|
style={active ? { backgroundColor: color } : undefined}
|
|
>
|
|
<span
|
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
|
style={{ backgroundColor: active ? 'rgba(255,255,255,0.8)' : color }}
|
|
/>
|
|
{label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Network section */}
|
|
<SectionDivider label="Network" />
|
|
<div className="flex flex-col gap-2">
|
|
<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>VLAN</FieldLabel>
|
|
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" mono />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hardware section */}
|
|
<SectionDivider label="Hardware" />
|
|
<div className="flex flex-col gap-2">
|
|
<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>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>
|
|
|
|
{/* Notes */}
|
|
<SectionDivider label="Notes" />
|
|
<div className="flex flex-col gap-1">
|
|
<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">
|
|
{deleteConfirm ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
<p className="text-center text-[10px] text-muted-foreground">Delete this device?</p>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
onClick={() => setDeleteConfirm(false)}
|
|
className="flex-1 rounded border border-default px-2 py-1.5 text-xs text-primary hover:bg-elevated"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => onDeleteNode(selectedNode!.id)}
|
|
className="flex-1 rounded bg-red-500/20 px-2 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setDeleteConfirm(true)}
|
|
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>
|
|
)
|
|
}
|