From c7be851c34fe39f2882e1a691bdd93579e7435cd Mon Sep 17 00:00:00 2001 From: findthelorax Date: Sun, 19 Apr 2026 23:00:32 -0400 Subject: [PATCH] feature: add support for services to use a path --- backend/tests/test_canvas.py | 9 +++ .../src/components/panels/DetailPanel.tsx | 53 +++++++++---- frontend/src/components/panels/Sidebar.tsx | 2 +- .../panels/__tests__/DetailPanel.test.ts | 21 ++++- .../panels/__tests__/DetailPanel.test.tsx | 36 ++++++++- frontend/src/types/index.ts | 3 +- frontend/src/utils/exportMarkdown.ts | 6 +- frontend/src/utils/serviceUrl.ts | 77 +++++++++++++++++-- 8 files changed, 179 insertions(+), 28 deletions(-) diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 7b25f25..3d7dadf 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -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) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 2deff7e..ce2499e 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -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 ? ( setEditingFor(null)} confirmLabel="Save" autoFocus /> ) : ( - handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> + handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> ) )} @@ -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
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()} />
- 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()} /> + 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()} />
+ 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()} />
@@ -619,14 +639,17 @@ const CATEGORY_COLORS: Record = { 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 = (
- {svc.service_name} + {svc.service_name} + {pathLabel && {pathLabel}}
- {svc.port}/{svc.protocol} + {portLabel}/{svc.protocol} {url && } diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index a8f7688..bf382d4 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -359,7 +359,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:

No pending devices

)} {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) diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.ts b/frontend/src/components/panels/__tests__/DetailPanel.test.ts index 786a341..92b3ee6 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.ts +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.ts @@ -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') + }) }) diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index 07066bd..8bef56f 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -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) + render() + 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() // 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', () => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2481349..a9bdaee 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 } diff --git a/frontend/src/utils/exportMarkdown.ts b/frontend/src/utils/exportMarkdown.ts index 19808c5..4dfef69 100644 --- a/frontend/src/utils/exportMarkdown.ts +++ b/frontend/src/utils/exportMarkdown.ts @@ -15,7 +15,11 @@ export function generateMarkdownTable(nodes: Node[]): 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), diff --git a/frontend/src/utils/serviceUrl.ts b/frontend/src/utils/serviceUrl.ts index b90d061..b2719e6 100644 --- a/frontend/src/utils/serviceUrl.ts +++ b/frontend/src/utils/serviceUrl.ts @@ -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)}` }