ea451885af
Extend splitIps to accept comma/semicolon/newline delimiters with dedupe, and reuse it in DetailPanel for both the IP list and the service-URL host. Fixes ServiceBadge links pointing at concatenated string when IPs were entered with ';' or newline separators. Adds tests for new delimiters.
761 lines
31 KiB
TypeScript
761 lines
31 KiB
TypeScript
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 { splitIps } 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<string | null>(null)
|
||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
|
||
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
|
||
const [groupName, setGroupName] = useState('')
|
||
const [creatingGroup, setCreatingGroup] = useState(false)
|
||
|
||
// Properties state
|
||
const [addingProp, setAddingProp] = useState(false)
|
||
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
||
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
||
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||
|
||
// Multi-select panel
|
||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||
|
||
if (multiSelected.length > 1) {
|
||
return (
|
||
<MultiSelectPanel
|
||
nodeIds={multiSelected}
|
||
nodes={nodes}
|
||
groupName={groupName}
|
||
setGroupName={setGroupName}
|
||
creatingGroup={creatingGroup}
|
||
setCreatingGroup={setCreatingGroup}
|
||
onCreateGroup={(name) => { 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 (
|
||
<GroupDetailPanel
|
||
node={node}
|
||
nodes={nodes}
|
||
onUngroup={() => { 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 ipAddresses = data.ip ? splitIps(data.ip) : []
|
||
const host = ipAddresses[0] ?? 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 (
|
||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
||
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} />
|
||
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
|
||
{data.response_time_ms !== undefined && (
|
||
<span className="ml-auto font-mono text-xs text-muted-foreground">{data.response_time_ms}ms</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
||
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
||
{data.hostname && (
|
||
<div className="flex justify-between gap-2 items-baseline">
|
||
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
|
||
<a href={`http://${data.hostname}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.hostname}>
|
||
{data.hostname}<ExternalLink size={10} className="shrink-0" />
|
||
</a>
|
||
</div>
|
||
)}
|
||
{ipAddresses.length > 0 && (
|
||
<div className="flex justify-between gap-2 items-start">
|
||
<span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'}</span>
|
||
<div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
|
||
{ipAddresses.map((ip, index) => (
|
||
<span key={`${ip}-${index}`} className="inline-flex items-center shrink-0 whitespace-nowrap">
|
||
<a
|
||
href={`http://${ip}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-xs font-mono text-[#00d4ff] hover:underline inline-flex items-center gap-1"
|
||
title={ip}
|
||
>
|
||
{ip}
|
||
<ExternalLink size={10} className="shrink-0" />
|
||
</a>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||
{data.os && <DetailRow label="OS" value={data.os} />}
|
||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||
</div>
|
||
|
||
{/* Properties section */}
|
||
<div className="px-4 py-3 border-t border-border">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||
<button
|
||
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||
>
|
||
<Plus size={10} /> Add
|
||
</button>
|
||
</div>
|
||
{addingProp && (
|
||
<PropertyForm
|
||
form={newProp}
|
||
onChange={setNewProp}
|
||
onConfirm={handleAddProp}
|
||
onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
|
||
confirmLabel="Add"
|
||
/>
|
||
)}
|
||
{properties.length > 0 && (
|
||
<div className="flex flex-col gap-1.5">
|
||
{properties.map((prop, i) =>
|
||
editingPropIndex === i ? (
|
||
<PropertyForm
|
||
key={`edit-${i}`}
|
||
form={editProp}
|
||
onChange={setEditProp}
|
||
onConfirm={handleSaveEditProp}
|
||
onCancel={() => setEditingPropIndex(null)}
|
||
confirmLabel="Save"
|
||
/>
|
||
) : (
|
||
<PropertyBadge
|
||
key={`${prop.key}-${i}`}
|
||
prop={prop}
|
||
onToggleVisible={() => handleTogglePropVisible(i)}
|
||
onEdit={() => handleStartEditProp(i)}
|
||
onRemove={() => handleRemoveProp(i)}
|
||
/>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
{properties.length === 0 && !addingProp && (
|
||
<p className="text-[10px] text-muted-foreground/50">No properties — click Add to define one.</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="px-4 py-3 border-t border-border">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
||
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
|
||
<Plus size={10} /> Add
|
||
</button>
|
||
</div>
|
||
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
|
||
{services.length > 0 && (
|
||
<div className="flex flex-col gap-1.5">
|
||
{services.map((svc, i) =>
|
||
editingIndex === i ? (
|
||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||
) : (
|
||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
|
||
</div>
|
||
|
||
{data.notes && (
|
||
<div className="px-4 py-3 border-t border-border">
|
||
<div className="text-xs text-muted-foreground mb-1">Notes</div>
|
||
<p className="text-xs text-foreground/80 whitespace-pre-wrap">{data.notes}</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
||
<Button size="sm" variant="secondary" className="flex-1 gap-1.5 cursor-pointer" onClick={() => onEdit(node.id)}>
|
||
<Edit size={14} /> Edit
|
||
</Button>
|
||
<Button size="sm" variant="destructive" className="gap-1.5 cursor-pointer" aria-label="Delete node" onClick={handleDelete}>
|
||
<Trash2 size={14} />
|
||
</Button>
|
||
</div>
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
// --- Multi-select panel ---
|
||
|
||
interface MultiSelectPanelProps {
|
||
nodeIds: string[]
|
||
nodes: Node<NodeData>[]
|
||
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<NodeData>[]
|
||
|
||
const handleCreate = () => {
|
||
const name = groupName.trim() || 'Group'
|
||
onCreateGroup(name)
|
||
}
|
||
|
||
return (
|
||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||
<div className="flex items-center gap-2">
|
||
<Layers size={14} className="text-[#00d4ff]" />
|
||
<span className="font-semibold text-sm text-foreground">{nodeIds.length} nodes selected</span>
|
||
</div>
|
||
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors">
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
||
{selectedNodes.map((n) => (
|
||
<div key={n.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs">
|
||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[n.data.status] }} />
|
||
<span className="truncate text-foreground font-medium">{n.data.label}</span>
|
||
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="px-4 py-3 border-t border-border space-y-2">
|
||
{creatingGroup ? (
|
||
<>
|
||
<Input
|
||
autoFocus
|
||
placeholder="Group name…"
|
||
value={groupName}
|
||
onChange={(e) => setGroupName(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') setCreatingGroup(false) }}
|
||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button size="sm" className="flex-1 h-7 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={handleCreate}>
|
||
Create Group
|
||
</Button>
|
||
<Button size="sm" variant="ghost" className="h-7 text-[10px]" onClick={() => setCreatingGroup(false)}>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
|
||
variant="ghost"
|
||
onClick={() => setCreatingGroup(true)}
|
||
>
|
||
<Layers size={13} /> Create Group
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
// --- Group detail panel ---
|
||
|
||
interface GroupDetailPanelProps {
|
||
node: Node<NodeData>
|
||
nodes: Node<NodeData>[]
|
||
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 (
|
||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Layers size={14} className="text-[#00d4ff] shrink-0" />
|
||
<span className="font-semibold text-sm text-foreground truncate">{node.data.label}</span>
|
||
</div>
|
||
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Status summary */}
|
||
<div className="flex items-center gap-4 px-4 py-3 border-b border-border text-xs">
|
||
<span className="text-muted-foreground">{children.length} node{children.length !== 1 ? 's' : ''}</span>
|
||
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}>● {onlineCount} online</span>}
|
||
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount} offline</span>}
|
||
</div>
|
||
|
||
{/* Children list */}
|
||
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
|
||
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
|
||
{children.map((child) => (
|
||
<button
|
||
key={child.id}
|
||
onClick={() => onSelectChild(child.id)}
|
||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
|
||
>
|
||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
|
||
<span className="truncate text-foreground font-medium">{child.data.label}</span>
|
||
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="px-4 py-3 border-t border-border space-y-2">
|
||
<button
|
||
onClick={onToggleBorder}
|
||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-[#21262d] transition-colors"
|
||
>
|
||
{showBorder ? <Eye size={13} /> : <EyeOff size={13} />}
|
||
{showBorder ? 'Hide border & title' : 'Show border & title'}
|
||
</button>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
className="w-full gap-2"
|
||
onClick={handleUngroup}
|
||
>
|
||
<Ungroup size={13} /> Ungroup
|
||
</Button>
|
||
</div>
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
// --- Helpers ---
|
||
|
||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||
return (
|
||
<div className="flex justify-between gap-2 items-baseline">
|
||
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
|
||
<span className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`} title={value}>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||
<Input value={form.service_name} onChange={(e) => 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()} />
|
||
<div className="flex gap-1.5">
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
value={form.port}
|
||
onChange={(e) => 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()}
|
||
/>
|
||
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
|
||
<option value="tcp">tcp</option>
|
||
<option value="udp">udp</option>
|
||
</select>
|
||
</div>
|
||
<Input value={form.path} onChange={(e) => 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()} />
|
||
<div className="flex gap-1.5">
|
||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
|
||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Property components ---
|
||
|
||
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
|
||
form: PropForm
|
||
onChange: (f: PropForm) => void
|
||
onConfirm: () => void
|
||
onCancel: () => void
|
||
confirmLabel: string
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||
<Input
|
||
value={form.key}
|
||
onChange={(e) => 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()}
|
||
/>
|
||
<Input
|
||
value={form.value}
|
||
onChange={(e) => 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 */}
|
||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||
<button
|
||
onClick={() => onChange({ ...form, icon: null })}
|
||
title="No icon"
|
||
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
|
||
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||
}`}
|
||
>
|
||
–
|
||
</button>
|
||
{PROPERTY_ICON_NAMES.map((name) => {
|
||
const Icon = PROPERTY_ICONS[name]
|
||
const active = form.icon === name
|
||
return (
|
||
<button
|
||
key={name}
|
||
onClick={() => onChange({ ...form, icon: name })}
|
||
title={name}
|
||
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
|
||
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||
}`}
|
||
>
|
||
{createElement(Icon, { size: 11 })}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
{/* Visible toggle */}
|
||
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.visible}
|
||
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
|
||
className="accent-[#00d4ff] w-3 h-3"
|
||
/>
|
||
<span className="text-[10px] text-muted-foreground">Show on node</span>
|
||
</label>
|
||
<div className="flex gap-1.5">
|
||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
|
||
{confirmLabel}
|
||
</Button>
|
||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
|
||
prop: NodeProperty
|
||
onToggleVisible: () => void
|
||
onEdit: () => void
|
||
onRemove: () => void
|
||
}) {
|
||
const Icon = resolvePropertyIcon(prop.icon)
|
||
return (
|
||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d' }}>
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
|
||
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
||
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<button
|
||
onClick={onToggleVisible}
|
||
title={prop.visible ? 'Hide on node' : 'Show on node'}
|
||
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
|
||
>
|
||
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
|
||
</button>
|
||
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
|
||
<Pencil size={10} />
|
||
</button>
|
||
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
|
||
<X size={10} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const CATEGORY_COLORS: Record<string, string> = {
|
||
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 pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||
|
||
return (
|
||
<div
|
||
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
|
||
style={{ background: '#21262d', borderColor: '#30363d' }}
|
||
>
|
||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||
|
||
{url ? (
|
||
<a
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="font-medium truncate min-w-0 flex-1"
|
||
style={{ color }}
|
||
title={svc.service_name}
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
{svc.service_name}
|
||
</a>
|
||
) : (
|
||
<span
|
||
className="font-medium truncate min-w-0 flex-1"
|
||
style={{ color }}
|
||
title={svc.service_name}
|
||
>
|
||
{svc.service_name}
|
||
</span>
|
||
)}
|
||
|
||
{pathLabel && (
|
||
<span
|
||
className="shrink-0 text-[#8b949e] text-right w-16 truncate"
|
||
title={pathLabel}
|
||
>
|
||
{pathLabel}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{svc.port != null && (
|
||
<span className="font-mono text-[#8b949e]">
|
||
{svc.port}/{svc.protocol}
|
||
</span>
|
||
)}
|
||
|
||
{url ? (
|
||
<a
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex w-2.5 h-2.5 items-center justify-center"
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<ExternalLink size={10} className="text-muted-foreground" />
|
||
</a>
|
||
) : (
|
||
<span className="w-2.5" />
|
||
)}
|
||
|
||
<button
|
||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer"
|
||
title="Edit service"
|
||
>
|
||
<Pencil size={10} />
|
||
</button>
|
||
|
||
<button
|
||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5 cursor-pointer"
|
||
title="Remove service"
|
||
>
|
||
<X size={10} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|