feat: add/remove services in detail panel + fix service URL detection
- Any TCP service (except port 22 and known non-HTTP protocols) is now clickable — fixes Sonarr, Radarr, Jellyfin, Proxmox web UI, etc. - HTTPS auto-detected for port 443/8443 or names containing ssl/tls/https - Non-HTTP blocklist: FTP, SMTP, DNS, LDAP, SMB, MySQL, Postgres, Redis, MongoDB, Kafka, Memcached, etc. - Inline "Add service" form in detail panel (name, port, tcp/udp) - Remove button (×) on hover for each service row - Group rect nodes excluded from detail panel - getServiceUrl extracted to utils/serviceUrl.ts - 13 new tests (79 total, all pass)
This commit is contained in:
@@ -1,20 +1,31 @@
|
||||
import { X, Edit, Trash2, ExternalLink } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus } 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 { getServiceUrl } from '@/utils/serviceUrl'
|
||||
|
||||
interface DetailPanelProps {
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode } = useCanvasStore()
|
||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
|
||||
const node = nodes.find((n) => n.id === selectedNodeId)
|
||||
|
||||
if (!node) return null
|
||||
const [addingService, setAddingService] = useState(false)
|
||||
const [newSvc, setNewSvc] = useState<{ port: string; protocol: 'tcp' | 'udp'; service_name: string }>({
|
||||
port: '',
|
||||
protocol: 'tcp',
|
||||
service_name: '',
|
||||
})
|
||||
|
||||
if (!node || node.data.type === 'groupRect') return null
|
||||
|
||||
const { data } = node
|
||||
const statusColor = STATUS_COLORS[data.status]
|
||||
const host = data.ip ?? data.hostname
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(`Delete "${data.label}"?`)) {
|
||||
@@ -22,6 +33,24 @@ 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(),
|
||||
}
|
||||
updateNode(node.id, { services: [...(data.services ?? []), svc] })
|
||||
setNewSvc({ port: '', protocol: 'tcp', service_name: '' })
|
||||
setAddingService(false)
|
||||
}
|
||||
|
||||
const handleRemoveService = (index: number) => {
|
||||
const updated = data.services.filter((_, i) => i !== index)
|
||||
updateNode(node.id, { services: updated })
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||
{/* Header */}
|
||||
@@ -53,24 +82,90 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{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()}
|
||||
/>
|
||||
<DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
{data.services.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2">Services ({data.services.length})</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{data.services.length > 0 ? ` (${data.services.length})` : ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setAddingService((v) => !v)}
|
||||
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 && (
|
||||
<div className="flex flex-col gap-1.5 mb-2 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input
|
||||
value={newSvc.service_name}
|
||||
onChange={(e) => setNewSvc((s) => ({ ...s, service_name: e.target.value }))}
|
||||
placeholder="Service name"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
value={newSvc.port}
|
||||
onChange={(e) => setNewSvc((s) => ({ ...s, 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"
|
||||
/>
|
||||
<select
|
||||
value={newSvc.protocol}
|
||||
onChange={(e) => setNewSvc((s) => ({ ...s, 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={handleAddService}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setAddingService(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.services.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.services.map((svc) => (
|
||||
<ServiceBadge key={`${svc.port}-${svc.protocol}`} svc={svc} ip={data.ip} />
|
||||
{data.services.map((svc, i) => (
|
||||
<ServiceBadge
|
||||
key={`${svc.port}-${svc.protocol}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{data.services.length === 0 && !addingService && (
|
||||
<p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{data.notes && (
|
||||
@@ -82,20 +177,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
|
||||
{/* 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)}
|
||||
>
|
||||
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
||||
<Edit size={14} /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1.5"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Button size="sm" variant="destructive" className="gap-1.5" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -126,39 +211,33 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
remote: '#8b949e',
|
||||
}
|
||||
|
||||
function getServiceUrl(svc: ServiceInfo, ip?: string): string | null {
|
||||
if (!ip) return null
|
||||
const name = svc.service_name.toLowerCase()
|
||||
const isHttps = name.includes('https') || name.includes('ssl') || svc.port === 443 || svc.port === 8443
|
||||
const isHttp = name.includes('http') || [80, 8080, 8000, 3000, 8888, 9000, 8090, 7080].includes(svc.port)
|
||||
if (isHttps) return `https://${ip}:${svc.port}`
|
||||
if (isHttp) return `http://${ip}:${svc.port}`
|
||||
return null
|
||||
}
|
||||
|
||||
function ServiceBadge({ svc, ip }: { svc: ServiceInfo; ip?: string }) {
|
||||
const url = getServiceUrl(svc, ip)
|
||||
function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string; onRemove: () => void }) {
|
||||
const url = getServiceUrl(svc, host)
|
||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
|
||||
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',
|
||||
...(url ? { cursor: 'pointer' } : {}),
|
||||
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="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
||||
</div>
|
||||
<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(); 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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import type { ServiceInfo } from '@/types'
|
||||
|
||||
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
|
||||
port,
|
||||
protocol,
|
||||
service_name,
|
||||
})
|
||||
|
||||
describe('getServiceUrl', () => {
|
||||
it('returns null when no host is provided', () => {
|
||||
expect(getServiceUrl(svc(80), undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for port 22 (SSH)', () => {
|
||||
expect(getServiceUrl(svc(22, 'tcp', 'ssh'), '192.168.1.1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for UDP services', () => {
|
||||
expect(getServiceUrl(svc(53, 'udp', 'dns'), '192.168.1.1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for known non-HTTP TCP ports', () => {
|
||||
expect(getServiceUrl(svc(3306, 'tcp', 'mysql'), '192.168.1.1')).toBeNull()
|
||||
expect(getServiceUrl(svc(5432, 'tcp', 'postgres'), '192.168.1.1')).toBeNull()
|
||||
expect(getServiceUrl(svc(6379, 'tcp', 'redis'), '192.168.1.1')).toBeNull()
|
||||
expect(getServiceUrl(svc(27017, 'tcp', 'mongodb'), '192.168.1.1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns http URL for standard HTTP port', () => {
|
||||
expect(getServiceUrl(svc(80, 'tcp', 'http'), '192.168.1.10')).toBe('http://192.168.1.10:80')
|
||||
})
|
||||
|
||||
it('returns https URL for port 443', () => {
|
||||
expect(getServiceUrl(svc(443, 'tcp', 'https'), '10.0.0.1')).toBe('https://10.0.0.1:443')
|
||||
})
|
||||
|
||||
it('returns https URL for port 8443', () => {
|
||||
expect(getServiceUrl(svc(8443), '10.0.0.1')).toBe('https://10.0.0.1:8443')
|
||||
})
|
||||
|
||||
it('returns https URL when service name contains ssl', () => {
|
||||
expect(getServiceUrl(svc(9443, 'tcp', 'my-ssl-app'), '10.0.0.1')).toBe('https://10.0.0.1:9443')
|
||||
})
|
||||
|
||||
it('returns http URL for Sonarr on port 8989', () => {
|
||||
expect(getServiceUrl(svc(8989, 'tcp', 'Sonarr'), '192.168.1.5')).toBe('http://192.168.1.5:8989')
|
||||
})
|
||||
|
||||
it('returns http URL for Radarr on port 7878', () => {
|
||||
expect(getServiceUrl(svc(7878, 'tcp', 'Radarr'), '192.168.1.5')).toBe('http://192.168.1.5:7878')
|
||||
})
|
||||
|
||||
it('returns http URL for Jellyfin on port 8096', () => {
|
||||
expect(getServiceUrl(svc(8096, 'tcp', 'Jellyfin'), '192.168.1.5')).toBe('http://192.168.1.5:8096')
|
||||
})
|
||||
|
||||
it('returns http URL for Proxmox web UI on port 8006', () => {
|
||||
expect(getServiceUrl(svc(8006, 'tcp', 'Proxmox'), '192.168.1.100')).toBe('http://192.168.1.100:8006')
|
||||
})
|
||||
|
||||
it('uses host string directly (works with both IP and hostname)', () => {
|
||||
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ServiceInfo } from '@/types'
|
||||
|
||||
// Ports that are definitely not HTTP/web services (port 22/SSH handled separately)
|
||||
const NON_HTTP_PORTS = new Set([
|
||||
21, // FTP
|
||||
23, // Telnet
|
||||
25, 465, 587, // SMTP
|
||||
53, // DNS
|
||||
110, 143, 993, 995, // IMAP / POP3
|
||||
389, 636, // LDAP
|
||||
445, // SMB
|
||||
514, // Syslog
|
||||
1433, // MSSQL
|
||||
3306, // MySQL
|
||||
5432, // PostgreSQL
|
||||
5672, // RabbitMQ AMQP
|
||||
6379, // Redis
|
||||
9092, // Kafka
|
||||
11211, // Memcached
|
||||
27017, 27018, // MongoDB
|
||||
])
|
||||
|
||||
export function getServiceUrl(svc: ServiceInfo, host?: string): string | null {
|
||||
if (!host) return null
|
||||
if (svc.port === 22) return null // SSH — no browser
|
||||
if (svc.protocol === 'udp') return null // UDP — not HTTP
|
||||
if (NON_HTTP_PORTS.has(svc.port)) return null
|
||||
|
||||
const name = svc.service_name.toLowerCase()
|
||||
const isHttps =
|
||||
name.includes('https') || name.includes('ssl') || name.includes('tls') ||
|
||||
svc.port === 443 || svc.port === 8443
|
||||
return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}`
|
||||
}
|
||||
Reference in New Issue
Block a user