feat: lasso selection, multi-select panel, and named groups
- Add lasso/box selection via selectionOnDrag (Space to pan, lasso by default) - Add lasso/pan toggle button in canvas controls (bottom-left) - Multi-select panel in right panel when 2+ nodes selected (including zones) - Create named Group node from selected nodes with bounding box math - Group node: resizable, inline rename, show/hide border toggle, status summary - GroupDetailPanel: lists members, online/offline count, ungroup action - Fix group persistence after save/reload (extend proxmox container map to include group nodes) - Fix groupRect serialization to preserve parent_id - Remove background color from group and proxmox container node wrappers - Add tests for all new store actions, GroupNode, MultiSelectPanel, GroupDetailPanel
This commit is contained in:
@@ -1,33 +1,75 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-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 } from '@/types'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode, snapshotHistory } = useCanvasStore()
|
||||
const node = nodes.find((n) => n.id === selectedNodeId)
|
||||
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)
|
||||
|
||||
// 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]
|
||||
@@ -43,11 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const handleAddService = () => {
|
||||
const port = parseInt(newSvc.port, 10)
|
||||
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
const svc: ServiceInfo = {
|
||||
port,
|
||||
protocol: newSvc.protocol,
|
||||
service_name: newSvc.service_name.trim(),
|
||||
}
|
||||
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
setNewSvc(EMPTY_FORM)
|
||||
setAddingForNode(null)
|
||||
@@ -72,9 +110,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const port = parseInt(editSvc.port, 10)
|
||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
const updated = services.map((svc, i) =>
|
||||
i === editingIndex
|
||||
? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() }
|
||||
: svc
|
||||
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
||||
)
|
||||
updateNode(node.id, { services: updated })
|
||||
setEditingFor(null)
|
||||
@@ -82,19 +118,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
|
||||
return (
|
||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<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"
|
||||
>
|
||||
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<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>
|
||||
@@ -103,21 +133,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<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 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>
|
||||
)}
|
||||
@@ -125,12 +147,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{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(data.last_seen).toLocaleString()} />
|
||||
)}
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{/* Hardware */}
|
||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
||||
@@ -141,64 +160,28 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services */}
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add service form */}
|
||||
{addingService && (
|
||||
<ServiceForm
|
||||
form={newSvc}
|
||||
onChange={setNewSvc}
|
||||
onConfirm={handleAddService}
|
||||
onCancel={() => setAddingForNode(null)}
|
||||
confirmLabel="Add"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{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
|
||||
/>
|
||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||
) : (
|
||||
<ServiceBadge
|
||||
key={`${svc.port}-${svc.protocol}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
onEdit={() => handleStartEdit(i)}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
<ServiceBadge key={`${svc.port}-${svc.protocol}-${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>
|
||||
)}
|
||||
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{data.notes && (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-1">Notes</div>
|
||||
@@ -206,7 +189,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<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" onClick={() => onEdit(node.id)}>
|
||||
<Edit size={14} /> Edit
|
||||
@@ -219,6 +201,167 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 formatStorage(gb: number): string {
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||
return `${gb} GB`
|
||||
@@ -228,24 +371,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
||||
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}
|
||||
>
|
||||
<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,
|
||||
}: {
|
||||
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
|
||||
onConfirm: () => void
|
||||
@@ -255,82 +388,31 @@ function ServiceForm({
|
||||
}) {
|
||||
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()}
|
||||
/>
|
||||
<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="number"
|
||||
value={form.port}
|
||||
onChange={(e) => onChange({ ...form, port: e.target.value })}
|
||||
placeholder="Port"
|
||||
min={1}
|
||||
max={65535}
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 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"
|
||||
>
|
||||
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff',
|
||||
database: '#a855f7',
|
||||
monitoring: '#39d353',
|
||||
storage: '#e3b341',
|
||||
security: '#f85149',
|
||||
remote: '#8b949e',
|
||||
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
|
||||
}) {
|
||||
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 inner = (
|
||||
<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',
|
||||
cursor: url ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<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', cursor: url ? 'pointer' : 'default' }}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
||||
@@ -338,30 +420,11 @@ function ServiceBadge({
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
||||
<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"
|
||||
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"
|
||||
title="Remove service"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
<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" 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" title="Remove service"><X size={10} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (url) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">
|
||||
{inner}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
|
||||
return inner
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user