From e40ac25b20fccc6ae5bdcd02aaae49005447604c Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 13 Aug 2026 10:52:15 +0200 Subject: [PATCH] feat(services): allow a per-service host override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node can serve several domains, so a service now carries an optional `host` that overrides the node ip/hostname when building its URL. Empty falls back to the node host, and the override accepts the same shapes (`host`, `host:port`, `https://host/…`). The canvas link and the detail panel badge both resolve through `getServiceUrl`, so they follow it. Closes #332 ha-relevant: yes --- .../src/components/modals/ServiceModal.tsx | 16 ++++++++ .../modals/__tests__/ServiceModal.test.tsx | 29 ++++++++++++++ .../src/components/panels/DetailPanel.tsx | 2 + .../panels/__tests__/DetailPanel.test.tsx | 40 +++++++++++++++++++ frontend/src/types/index.ts | 4 ++ .../src/utils/__tests__/serviceForm.test.ts | 8 ++-- .../src/utils/__tests__/serviceUrl.test.ts | 33 +++++++++++++++ frontend/src/utils/serviceForm.ts | 4 ++ frontend/src/utils/serviceUrl.ts | 7 +++- 9 files changed, 138 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/modals/ServiceModal.tsx b/frontend/src/components/modals/ServiceModal.tsx index bbbc7f0..1ad3641 100644 --- a/frontend/src/components/modals/ServiceModal.tsx +++ b/frontend/src/components/modals/ServiceModal.tsx @@ -45,11 +45,13 @@ export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Se const port = trimmedPort === '' ? undefined : Number.parseInt(trimmedPort, 10) if (port != null && (Number.isNaN(port) || port < 1 || port > 65535)) return const path = form.path.trim() + const hostOverride = form.host.trim() onSubmit({ service_name: name, protocol: form.protocol, port, path: path || undefined, + host: hostOverride || undefined, icon: form.icon, }) onClose() @@ -134,6 +136,20 @@ export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Se + {/* Host override */} +
+ + set('host', e.target.value)} + placeholder="Node host (app.example.com)" + className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" + /> + + Overrides the node host for this service only. + +
+ {/* Path */}
diff --git a/frontend/src/components/modals/__tests__/ServiceModal.test.tsx b/frontend/src/components/modals/__tests__/ServiceModal.test.tsx index a5d1c1c..ae71bca 100644 --- a/frontend/src/components/modals/__tests__/ServiceModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ServiceModal.test.tsx @@ -28,6 +28,7 @@ describe('ServiceModal', () => { protocol: 'udp', port: 80, path: '/admin', + host: undefined, icon: undefined, }) expect(onClose).toHaveBeenCalledOnce() @@ -117,6 +118,34 @@ describe('ServiceModal', () => { submit('Save') expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ path: undefined })) }) + + it('prefills and clears the host override', () => { + const withHost = serviceToForm({ port: 443, protocol: 'tcp', service_name: 'blog', host: 'blog.example.com' }) + const { onSubmit } = setup({ initial: withHost, confirmLabel: 'Save' }) + const input = screen.getByPlaceholderText('Node host (app.example.com)') as HTMLInputElement + expect(input.value).toBe('blog.example.com') + fireEvent.change(input, { target: { value: '' } }) + submit('Save') + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: undefined })) + }) + }) + + describe('host override', () => { + it('submits a trimmed host override', () => { + const { onSubmit } = setup() + fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'blog' } }) + fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: ' blog.example.com ' } }) + submit() + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: 'blog.example.com' })) + }) + + it('submits undefined when the host is left blank', () => { + const { onSubmit } = setup() + fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'blog' } }) + fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: ' ' } }) + submit() + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: undefined })) + }) }) describe('icon picker', () => { diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 5e5a30d..8381c79 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -102,6 +102,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { protocol: data.protocol, service_name: data.service_name, ...(data.path ? { path: data.path } : {}), + ...(data.host ? { host: data.host } : {}), ...(data.icon ? { icon: data.icon } : {}), } updateNode(node.id, { services: [...services, svc] }) @@ -115,6 +116,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { service_name: data.service_name, port: data.port, path: data.path, + host: data.host, icon: data.icon, } : svc diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index b3241e2..3392fea 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -445,6 +445,46 @@ describe('DetailPanel', () => { expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined() }) + it('stores a host override on the added service and links it', () => { + const updateNode = vi.fn() + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [makeNode({ ip: '192.168.1.10' })], + 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: 'blog' } }) + fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '443' } }) + fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: 'blog.example.com' } }) + fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement) + + expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'blog', host: 'blog.example.com' }) + }) + + it('links a service badge to its host override rather than the node IP', () => { + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [makeNode({ ip: '192.168.1.10', services: [{ port: 443, protocol: 'tcp', service_name: 'blog', host: 'blog.example.com' }] })], + selectedNodeId: 'n1', + selectedNodeIds: [], + setSelectedNode: vi.fn(), + deleteNode: vi.fn(), + updateNode: vi.fn(), + snapshotHistory: vi.fn(), + createGroup: vi.fn(), + ungroup: vi.fn(), + } as unknown as ReturnType) + render() + expect(screen.getByRole('link', { name: 'blog' }).getAttribute('href')).toBe('https://blog.example.com:443') + }) + it('calls updateNode without the removed service when X is clicked', () => { const updateNode = vi.fn() const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 93027ee..ce385b0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -93,6 +93,10 @@ export interface ServiceInfo { path?: string icon?: string category?: string + /** Overrides the node host when building the service URL — one node can serve + * several domains. Same accepted shapes as a node `ip`/`hostname` + * (`host`, `host:port`, `https://host/…`). */ + host?: string } export type ServiceStatus = 'online' | 'offline' | 'unknown' diff --git a/frontend/src/utils/__tests__/serviceForm.test.ts b/frontend/src/utils/__tests__/serviceForm.test.ts index 3889d9a..7ceed76 100644 --- a/frontend/src/utils/__tests__/serviceForm.test.ts +++ b/frontend/src/utils/__tests__/serviceForm.test.ts @@ -3,21 +3,23 @@ import { serviceToForm, EMPTY_SERVICE_FORM } from '../serviceForm' describe('serviceToForm', () => { it('maps a service onto string form fields', () => { - expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', icon: 'database' })).toEqual({ + expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', host: 'svc.example.com', icon: 'database' })).toEqual({ service_name: 'svc', port: '443', protocol: 'udp', path: '/x', + host: 'svc.example.com', icon: 'database', }) }) - it('blanks out absent port and path', () => { + it('blanks out absent port, path and host', () => { expect(serviceToForm({ protocol: 'tcp', service_name: 'svc' })).toEqual({ service_name: 'svc', port: '', protocol: 'tcp', path: '', + host: '', icon: undefined, }) }) @@ -27,6 +29,6 @@ describe('serviceToForm', () => { }) it('starts from a blank tcp form', () => { - expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '' }) + expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '', host: '' }) }) }) diff --git a/frontend/src/utils/__tests__/serviceUrl.test.ts b/frontend/src/utils/__tests__/serviceUrl.test.ts index 92b3ee6..e6c11c3 100644 --- a/frontend/src/utils/__tests__/serviceUrl.test.ts +++ b/frontend/src/utils/__tests__/serviceUrl.test.ts @@ -80,4 +80,37 @@ describe('getServiceUrl', () => { 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') }) + + describe('service host override', () => { + const withHost = (host: string, port?: number, path?: string): ServiceInfo => + ({ ...svc(port, 'tcp', 'app', path), host }) + + it('uses the service host instead of the node one', () => { + expect(getServiceUrl(withHost('blog.example.com', 8080), '192.168.1.10')).toBe('http://blog.example.com:8080') + }) + + it('falls back to the node host when the override is blank', () => { + expect(getServiceUrl(withHost(' ', 8080), '192.168.1.10')).toBe('http://192.168.1.10:8080') + }) + + it('resolves against the override even when the node has no host', () => { + expect(getServiceUrl(withHost('blog.example.com', 80), undefined)).toBe('http://blog.example.com:80') + }) + + it('honours a scheme carried by the override', () => { + expect(getServiceUrl(withHost('https://blog.example.com'), '192.168.1.10')).toBe('https://blog.example.com') + }) + + it('takes the port from the override when the service has none', () => { + expect(getServiceUrl(withHost('blog.example.com:8443'), '192.168.1.10:80')).toBe('https://blog.example.com:8443') + }) + + it('appends the service path to the override host', () => { + expect(getServiceUrl(withHost('blog.example.com', 3000, 'admin'), '192.168.1.10')).toBe('http://blog.example.com:3000/admin') + }) + + it('still returns null for a UDP service', () => { + expect(getServiceUrl({ ...svc(53, 'udp', 'dns'), host: 'dns.example.com' }, '192.168.1.10')).toBeNull() + }) + }) }) diff --git a/frontend/src/utils/serviceForm.ts b/frontend/src/utils/serviceForm.ts index 782eb6b..0a1e3f1 100644 --- a/frontend/src/utils/serviceForm.ts +++ b/frontend/src/utils/serviceForm.ts @@ -6,6 +6,7 @@ export interface ServiceFormData { port: string protocol: 'tcp' | 'udp' path: string + host: string icon?: string } @@ -15,6 +16,7 @@ export interface ServiceSubmitData { protocol: 'tcp' | 'udp' port: number | undefined path: string | undefined + host: string | undefined icon: string | undefined } @@ -23,6 +25,7 @@ export const EMPTY_SERVICE_FORM: ServiceFormData = { port: '', protocol: 'tcp', path: '', + host: '', } /** Turn a stored service into the string-based form shape. */ @@ -32,6 +35,7 @@ export function serviceToForm(svc: ServiceInfo): ServiceFormData { port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, path: svc.path ?? '', + host: svc.host ?? '', icon: svc.icon, } } diff --git a/frontend/src/utils/serviceUrl.ts b/frontend/src/utils/serviceUrl.ts index b2719e6..21bce0c 100644 --- a/frontend/src/utils/serviceUrl.ts +++ b/frontend/src/utils/serviceUrl.ts @@ -78,10 +78,13 @@ function formatHostname(hostname: string): string { } export function getServiceUrl(svc: ServiceInfo, host?: string): string | null { - if (!host) return null + // A node can serve several domains, so a service may carry its own host that + // overrides the node one. + const effectiveHost = svc.host?.trim() || host + if (!effectiveHost) return null if (svc.protocol === 'udp') return null // UDP — not HTTP - const parts = parseHostParts(host) + const parts = parseHostParts(effectiveHost) if (!parts?.hostname) return null const effectivePort = svc.port ?? parts.port