import { createElement, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useCanvasStore } from '@/stores/canvasStore' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' import { primaryIp } from '@/utils/maskIp' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import type { Node } from '@xyflow/react' interface DetailPanelProps { onEdit: (id: string) => void } type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string } const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' } type PropForm = { key: string; value: string; icon: string | null; visible: boolean } const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } export function DetailPanel({ onEdit }: DetailPanelProps) { const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() const [addingForNode, setAddingForNode] = useState(null) const [newSvc, setNewSvc] = useState(EMPTY_FORM) const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null) const [editSvc, setEditSvc] = useState(EMPTY_FORM) const [groupName, setGroupName] = useState('') const [creatingGroup, setCreatingGroup] = useState(false) // Properties state const [addingProp, setAddingProp] = useState(false) const [newProp, setNewProp] = useState(EMPTY_PROP) const [editingPropIndex, setEditingPropIndex] = useState(null) const [editProp, setEditProp] = useState(EMPTY_PROP) // Multi-select panel const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) if (multiSelected.length > 1) { return ( { createGroup(multiSelected, name); setGroupName(''); setCreatingGroup(false) }} onClose={() => setSelectedNode(null)} /> ) } const node = nodes.find((n) => n.id === selectedNodeId) if (!node || node.data.type === 'groupRect') return null // Group detail panel if (node.data.type === 'group') { return ( { ungroup(node.id) }} onToggleBorder={() => { snapshotHistory() updateNode(node.id, { custom_colors: { ...node.data.custom_colors, show_border: !(node.data.custom_colors?.show_border !== false), }, }) }} onClose={() => setSelectedNode(null)} onSelectChild={(id) => setSelectedNode(id)} /> ) } // Normal single-node panel const addingService = addingForNode === node.id const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null const { data } = node const services = data.services ?? [] const statusColor = STATUS_COLORS[data.status] const host = data.ip ?? data.hostname const handleDelete = () => { if (confirm(`Delete "${data.label}"?`)) { snapshotHistory() deleteNode(node.id) } } const handleAddService = () => { const trimmedPort = newSvc.port.trim() const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10) if (!newSvc.service_name.trim()) return if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return snapshotHistory() const path = newSvc.path.trim() const svc: ServiceInfo = { ...(port != null ? { port } : {}), protocol: newSvc.protocol, service_name: newSvc.service_name.trim(), ...(path ? { path } : {}), } updateNode(node.id, { services: [...services, svc] }) setNewSvc(EMPTY_FORM) setAddingForNode(null) } const handleRemoveService = (index: number) => { snapshotHistory() const updated = services.filter((_, i) => i !== index) updateNode(node.id, { services: updated }) if (editingIndex === index) setEditingFor(null) } const handleStartEdit = (index: number) => { const svc = services[index] if (!svc) return setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' }) setEditingFor({ nodeId: node.id, index }) setAddingForNode(null) } const handleSaveEdit = () => { if (editingIndex === null) return const trimmedPort = editSvc.port.trim() const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10) if (!editSvc.service_name.trim()) return if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return snapshotHistory() const path = editSvc.path.trim() const updated = services.map((svc, i) => i === editingIndex ? { ...svc, protocol: editSvc.protocol, service_name: editSvc.service_name.trim(), ...(port != null ? { port } : { port: undefined }), ...(path ? { path } : { path: undefined }), } : svc ) updateNode(node.id, { services: updated }) setEditingFor(null) } // --- Property handlers --- const properties: NodeProperty[] = data.properties ?? [] const handleAddProp = () => { if (!newProp.key.trim() || !newProp.value.trim()) return snapshotHistory() const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible } updateNode(node.id, { properties: [...properties, prop] }) setNewProp(EMPTY_PROP) setAddingProp(false) } const handleRemoveProp = (index: number) => { snapshotHistory() updateNode(node.id, { properties: properties.filter((_, i) => i !== index) }) if (editingPropIndex === index) setEditingPropIndex(null) } const handleTogglePropVisible = (index: number) => { snapshotHistory() updateNode(node.id, { properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p), }) } const handleStartEditProp = (index: number) => { const p = properties[index] if (!p) return setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible }) setEditingPropIndex(index) setAddingProp(false) } const handleSaveEditProp = () => { if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return snapshotHistory() updateNode(node.id, { properties: properties.map((p, i) => i === editingPropIndex ? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible } : p ), }) setEditingPropIndex(null) } return ( ) } // --- Multi-select panel --- interface MultiSelectPanelProps { nodeIds: string[] nodes: Node[] groupName: string setGroupName: (v: string) => void creatingGroup: boolean setCreatingGroup: (v: boolean) => void onCreateGroup: (name: string) => void onClose: () => void } function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGroup, setCreatingGroup, onCreateGroup, onClose }: MultiSelectPanelProps) { const selectedNodes = nodeIds.map((id) => nodes.find((n) => n.id === id)).filter(Boolean) as Node[] const handleCreate = () => { const name = groupName.trim() || 'Group' onCreateGroup(name) } return ( ) } // --- Group detail panel --- interface GroupDetailPanelProps { node: Node nodes: Node[] onUngroup: () => void onToggleBorder: () => void onClose: () => void onSelectChild: (id: string) => void } function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) { const children = nodes.filter((n) => n.parentId === node.id) const onlineCount = children.filter((n) => n.data.status === 'online').length const offlineCount = children.filter((n) => n.data.status === 'offline').length const showBorder = node.data.custom_colors?.show_border !== false const handleUngroup = () => { if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) { onUngroup() } } return ( ) } // --- Helpers --- function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label} {value}
) } function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: { form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string } onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void onConfirm: () => void onCancel: () => void confirmLabel: string autoFocus?: boolean }) { const setPort = (value: string) => { const digitsOnly = value.replace(/\D/g, '').slice(0, 5) onChange({ ...form, port: digitsOnly }) } const clampPort = (value: string) => { if (!value) return '' const parsed = Number.parseInt(value, 10) if (!Number.isFinite(parsed)) return '' return String(Math.max(1, Math.min(65535, parsed))) } return (
onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
setPort(e.target.value)} onBlur={() => onChange({ ...form, port: clampPort(form.port) })} placeholder="Port" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
) } // --- Property components --- function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: { form: PropForm onChange: (f: PropForm) => void onConfirm: () => void onCancel: () => void confirmLabel: string }) { return (
onChange({ ...form, key: e.target.value })} placeholder="Label (e.g. CPU Model)" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> onChange({ ...form, value: e.target.value })} placeholder="Value (e.g. i7-12700K)" className="bg-[#21262d] border-[#30363d] text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> {/* Icon picker */}
{PROPERTY_ICON_NAMES.map((name) => { const Icon = PROPERTY_ICONS[name] const active = form.icon === name return ( ) })}
{/* Visible toggle */}
) } function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: { prop: NodeProperty onToggleVisible: () => void onEdit: () => void onRemove: () => void }) { const Icon = resolvePropertyIcon(prop.icon) return (
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })} {prop.key} · {prop.value}
) } const CATEGORY_COLORS: Record = { web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e', } function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { const url = getServiceUrl(svc, host) const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' const portLabel = svc.port != null ? String(svc.port) : 'host' const pathLabel = svc.path?.trim() ? svc.path.trim() : null const inner = (
{svc.service_name} {pathLabel && {pathLabel}}
{portLabel}/{svc.protocol} {url && }
) if (url) return {inner} return inner }