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:
Pouzor
2026-03-10 17:29:41 +01:00
parent 1e72366d03
commit 1f1dfd807b
3 changed files with 223 additions and 44 deletions
+123 -44
View File
@@ -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')
})
})
+34
View File
@@ -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}`
}