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