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:
committed by
Pouzor - Rémy Jardient
parent
e93aef3871
commit
e40ac25b20
@@ -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' }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user