feature: add support for services to use a path
This commit is contained in:
@@ -453,6 +453,15 @@ async def test_save_canvas_persists_services_and_notes(client: AsyncClient, head
|
||||
assert node["notes"] == "My NAS device"
|
||||
|
||||
|
||||
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
|
||||
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
|
||||
n1 = node_payload(ip="192.168.1.50:8080", services=services)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["services"] == services
|
||||
|
||||
|
||||
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
@@ -13,8 +13,8 @@ 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: '' }
|
||||
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 }
|
||||
@@ -94,10 +94,18 @@ 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 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 svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
||||
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)
|
||||
@@ -113,18 +121,29 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const handleStartEdit = (index: number) => {
|
||||
const svc = services[index]
|
||||
if (!svc) return
|
||||
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
|
||||
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 port = parseInt(editSvc.port, 10)
|
||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) 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, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
||||
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)
|
||||
@@ -280,7 +299,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
editingIndex === i ? (
|
||||
<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 ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -480,8 +499,8 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
@@ -491,12 +510,13 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo
|
||||
<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="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()} />
|
||||
<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-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 (/dashboard)" 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>
|
||||
@@ -619,14 +639,17 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
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 = (
|
||||
<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>
|
||||
<span className="font-medium truncate" style={{ color }} title={svc.service_name}>{svc.service_name}</span>
|
||||
{pathLabel && <span className="truncate text-[#8b949e]" title={pathLabel}>{pathLabel}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
||||
<span className="font-mono text-[#8b949e]">{portLabel}/{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>
|
||||
|
||||
@@ -359,7 +359,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||
)}
|
||||
{devices.map((d) => {
|
||||
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
|
||||
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
|
||||
const titleService = namedService
|
||||
?? d.services.find((s) => s.port === 80)
|
||||
?? d.services.find((s) => s.port === 443)
|
||||
|
||||
@@ -2,10 +2,11 @@ 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,
|
||||
const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
|
||||
...(port != null ? { port } : {}),
|
||||
protocol,
|
||||
service_name,
|
||||
...(path ? { path } : {}),
|
||||
})
|
||||
|
||||
describe('getServiceUrl', () => {
|
||||
@@ -63,4 +64,20 @@ describe('getServiceUrl', () => {
|
||||
it('uses host string directly (works with both IP and hostname)', () => {
|
||||
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
|
||||
})
|
||||
|
||||
it('uses the node port when the host already includes one', () => {
|
||||
expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080')
|
||||
})
|
||||
|
||||
it('lets the service port override the node port', () => {
|
||||
expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000')
|
||||
})
|
||||
|
||||
it('appends a normalized path to the final URL', () => {
|
||||
expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login')
|
||||
})
|
||||
|
||||
it('supports path-only services inheriting the node port', () => {
|
||||
expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -293,9 +293,35 @@ describe('DetailPanel', () => {
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path'), { target: { value: '/admin' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
|
||||
})
|
||||
|
||||
it('allows adding a service without a port', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ ip: '192.168.1.10:8080' })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/dashboard)'), { target: { value: 'healthz' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' })
|
||||
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls updateNode without the removed service when X is clicked', () => {
|
||||
@@ -332,15 +358,17 @@ describe('DetailPanel', () => {
|
||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||
|
||||
it('shows edit form pre-filled when pencil is clicked', () => {
|
||||
setupStore({ services: [svc] })
|
||||
setupStore({ services: [{ ...svc, path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
||||
const editBtn = screen.getByTitle('Edit service')
|
||||
fireEvent.click(editBtn)
|
||||
const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement
|
||||
expect(nameInput.value).toBe('nginx')
|
||||
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
||||
const portInput = screen.getByPlaceholderText('Port (/dashboard)') as HTMLInputElement
|
||||
expect(portInput.value).toBe('80')
|
||||
const pathInput = screen.getByPlaceholderText('Path (/dashboard)') as HTMLInputElement
|
||||
expect(pathInput.value).toBe('/admin')
|
||||
})
|
||||
|
||||
it('calls updateNode with updated values on Save', () => {
|
||||
@@ -359,11 +387,13 @@ describe('DetailPanel', () => {
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Service name')
|
||||
fireEvent.change(nameInput, { target: { value: 'apache' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/dashboard)'), { target: { value: '/ui' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
|
||||
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
|
||||
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/ui')
|
||||
})
|
||||
|
||||
it('cancels edit without updating', () => {
|
||||
|
||||
@@ -36,9 +36,10 @@ export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
|
||||
export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none'
|
||||
|
||||
export interface ServiceInfo {
|
||||
port: number
|
||||
port?: number
|
||||
protocol: 'tcp' | 'udp'
|
||||
service_name: string
|
||||
path?: string
|
||||
icon?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
||||
.map((n) => {
|
||||
const d = n.data
|
||||
const services = d.services?.length
|
||||
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
|
||||
? d.services.map((s) => {
|
||||
const port = s.port != null ? `:${s.port}` : ''
|
||||
const path = s.path?.trim() ? s.path.trim() : ''
|
||||
return `${s.service_name}${port}${path}`
|
||||
}).join(', ')
|
||||
: EMPTY
|
||||
return [
|
||||
cell(d.label),
|
||||
|
||||
@@ -20,15 +20,82 @@ const NON_HTTP_PORTS = new Set([
|
||||
27017, 27018, // MongoDB
|
||||
])
|
||||
|
||||
function splitFirstHost(host: string): string {
|
||||
return host.split(',')[0]?.trim() ?? ''
|
||||
}
|
||||
|
||||
function parsePort(port: string): number | undefined {
|
||||
if (!/^\d+$/.test(port)) return undefined
|
||||
const parsed = Number.parseInt(port, 10)
|
||||
return parsed >= 1 && parsed <= 65535 ? parsed : undefined
|
||||
}
|
||||
|
||||
function parseHostParts(host: string): { protocol?: 'http' | 'https'; hostname: string; port?: number } | null {
|
||||
const firstHost = splitFirstHost(host)
|
||||
if (!firstHost) return null
|
||||
|
||||
if (firstHost.startsWith('http://') || firstHost.startsWith('https://')) {
|
||||
const url = new URL(firstHost)
|
||||
return {
|
||||
protocol: url.protocol === 'https:' ? 'https' : 'http',
|
||||
hostname: url.hostname,
|
||||
port: parsePort(url.port),
|
||||
}
|
||||
}
|
||||
|
||||
if (firstHost.startsWith('[')) {
|
||||
const bracketIndex = firstHost.indexOf(']')
|
||||
if (bracketIndex === -1) return { hostname: firstHost }
|
||||
const hostname = firstHost.slice(1, bracketIndex)
|
||||
const remainder = firstHost.slice(bracketIndex + 1)
|
||||
return {
|
||||
hostname,
|
||||
port: remainder.startsWith(':') ? parsePort(remainder.slice(1)) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const colonCount = (firstHost.match(/:/g) ?? []).length
|
||||
if (colonCount === 1) {
|
||||
const [hostname, rawPort] = firstHost.split(':')
|
||||
const parsedPort = parsePort(rawPort)
|
||||
if (hostname && parsedPort != null) {
|
||||
return { hostname, port: parsedPort }
|
||||
}
|
||||
}
|
||||
|
||||
return { hostname: firstHost }
|
||||
}
|
||||
|
||||
function normalizePath(path?: string): string {
|
||||
const trimmed = path?.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed === '/') return '/'
|
||||
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
}
|
||||
|
||||
function formatHostname(hostname: string): string {
|
||||
return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname
|
||||
}
|
||||
|
||||
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 parts = parseHostParts(host)
|
||||
if (!parts?.hostname) return null
|
||||
|
||||
const effectivePort = svc.port ?? parts.port
|
||||
if (effectivePort === 22) return null // SSH — no browser
|
||||
if (effectivePort != null && NON_HTTP_PORTS.has(effectivePort)) return null
|
||||
|
||||
const name = svc.service_name.toLowerCase()
|
||||
const isHttps =
|
||||
const protocol = parts.protocol ?? (
|
||||
name.includes('https') || name.includes('ssl') || name.includes('tls') ||
|
||||
svc.port === 443 || svc.port === 8443
|
||||
return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}`
|
||||
effectivePort === 443 || effectivePort === 8443
|
||||
? 'https'
|
||||
: 'http'
|
||||
)
|
||||
const base = `${protocol}://${formatHostname(parts.hostname)}`
|
||||
const port = effectivePort != null ? `:${effectivePort}` : ''
|
||||
return `${base}${port}${normalizePath(svc.path)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user