feat(services): allow a per-service host override

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
This commit is contained in:
Pouzor
2026-08-13 19:16:47 +02:00
committed by Pouzor - Rémy Jardient
parent e93aef3871
commit e40ac25b20
9 changed files with 138 additions and 5 deletions
@@ -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
</div>
</div>
{/* Host override */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Host</Label>
<Input
value={form.host}
onChange={(e) => set('host', e.target.value)}
placeholder="Node host (app.example.com)"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
<span className="text-[10px] text-muted-foreground/60">
Overrides the node host for this service only.
</span>
</div>
{/* Path */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Path</Label>
@@ -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', () => {
@@ -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
@@ -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<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: '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<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
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' }
+4
View File
@@ -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'
@@ -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: '' })
})
})
@@ -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()
})
})
})
+4
View File
@@ -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,
}
}
+5 -2
View File
@@ -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