Skip to content

feat: configurable server aliases for dashboard API URL hints#634

Merged
jundot merged 2 commits intojundot:mainfrom
jasonpaulso:feature/server-aliases-pr
Apr 14, 2026
Merged

feat: configurable server aliases for dashboard API URL hints#634
jundot merged 2 commits intojundot:mainfrom
jasonpaulso:feature/server-aliases-pr

Conversation

@jasonpaulso
Copy link
Copy Markdown

Summary

Adds a configurable list of server hostnames/IPs (aliases) to the admin dashboard so users on multi-interface hosts can pick which address the displayed API URLs should use, instead of editing them by hand.

Why

Today the Status tab shows http://<host>:<port>/... for the OpenAI/Claude endpoints and the Claude Code with oMLX launch command. When the server is bound to 127.0.0.1 (or 0.0.0.0), that single rendered host doesn't necessarily match the address you actually want to connect from — laptop on Tailscale, phone on the LAN, another container on *.local, etc. The previous workflow was: copy the claude launch command into a text file and hand-edit the ANTHROPIC_BASE_URL to whatever interface was needed. Repeated for every endpoint, every network change.

This PR replaces that with a small dropdown in the API Endpoints card that drives every URL block on the page reactively.

What it does

  • Auto-detects sensible defaults at startup: localhost/127.0.0.1, gethostname(), <hostname>.local (mDNS/Bonjour), getfqdn(), and non-loopback IPv4 addresses from psutil (with getaddrinfo fallback). PTR/*.arpa records are filtered out.
  • Persists user-curated aliases in ServerSettings.server_aliases. Auto-detection only runs when the list is empty, so manually configured values are never clobbered on restart.
  • New endpoint GET /admin/api/server-info returns {host, port, aliases} and falls back to live detection if the persisted list is empty.
  • Saves via the existing settings flow: GlobalSettingsRequest accepts server_aliases, with hostname/IP validation that rejects invalid entries with HTTP 400.
  • Dashboard UI: a host selector appears in the API Endpoints header (only when there's more than one alias). All <code> URL blocks bind to a single `displayHost` getter that honors the selection — OpenAI URL, Claude URL, and the full `ANTHROPIC_BASE_URL` inside the Claude Code launch command all update in lockstep.

Files

File Change
`omlx/utils/network.py` New module — alias detection + validation
`omlx/settings.py` `ServerSettings.server_aliases: list[str]`
`omlx/admin/routes.py` `/api/server-info` endpoint + `server_aliases` save/validate path
`omlx/server.py` Lifespan auto-population on first run
`omlx/admin/static/js/dashboard.js` `serverAliases`/`selectedAlias` state + `loadServerInfo()` + `displayHost` honors selection
`omlx/admin/templates/dashboard/_status.html` Alias dropdown in the API Endpoints header

267 lines added, 0 removed. No breaking changes — `server_aliases` defaults to `[]`, and existing settings files are read with `.get()` so old configs are forward-compatible.

Test plan

Verified end-to-end against a live FastAPI test client:

  • `GET /admin/api/server-info` returns persisted aliases
  • Empty alias list → endpoint falls back to live auto-detection
  • `POST /admin/api/global-settings` with `server_aliases` saves and shows up in `runtime_applied`
  • Invalid alias (e.g. `'bad name!!!'`) → HTTP 400 with descriptive error
  • `GET /admin/api/global-settings` includes `server.server_aliases` in the response
  • Round-trip persistence to `settings.json` and reload
  • `ruff check` clean on the new module; all touched Python files compile
  • Manual smoke test in the browser: dropdown appears, switching the selected alias updates the OpenAI URL, the Claude URL, and the `ANTHROPIC_BASE_URL` inside the Claude Code launch command simultaneously

Users can now configure multiple server hostnames/IPs (e.g. Tailscale
mDNS, LAN IP, localhost) and pick which one the admin dashboard uses
when rendering API URL hints. This makes the dashboard usable on
multi-interface hosts without forcing users to memorize the right
address for each network surface.

- Add ServerSettings.server_aliases (persisted, validated)
- New utility omlx/utils/network.py: detects hostname, FQDN, mDNS, and
  non-loopback IPv4 addresses; filters PTR/arpa noise; validates aliases
- New /admin/api/server-info endpoint returns host/port/aliases, falling
  back to live detection when the persisted list is empty
- GlobalSettingsRequest accepts server_aliases (validated server-side)
- Lifespan auto-populates aliases on startup when the list is empty,
  preserving user-curated values across restarts
- Dashboard adds a host selector in the API Endpoints section; the
  displayHost getter honors the selection so all <code> URL blocks
  update reactively via Alpine
Copilot AI review requested due to automatic review settings April 7, 2026 03:38
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds server “alias” support so the admin dashboard can render API URL hints (and the Claude Code launch command) using a user-selectable host on multi-interface machines.

Changes:

  • Introduces alias detection + validation utilities and persists aliases in ServerSettings.server_aliases.
  • Adds /admin/api/server-info and supports saving server_aliases via the existing global settings update route.
  • Updates the dashboard to load aliases, show a host dropdown when applicable, and bind all displayed URLs to the selected host.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
omlx/utils/network.py New helpers for alias detection/validation.
omlx/settings.py Adds server_aliases to persisted server settings.
omlx/admin/routes.py Adds server-info endpoint + server_aliases save/validate path.
omlx/server.py Auto-populates aliases on first run when none are persisted.
omlx/admin/static/js/dashboard.js Loads server info, stores alias selection, and uses it for displayed hosts.
omlx/admin/templates/dashboard/_status.html Adds the alias dropdown UI in the API Endpoints header.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1072 to 1077
get displayHost() {
if (this.selectedAlias) return this.selectedAlias;
const host = this.stats.host || '127.0.0.1';
if (host === '0.0.0.0') return 'your-ip-address';
if (host === 'localhost') return '127.0.0.1';
return host;
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

displayHost returns selectedAlias verbatim, but the template builds URLs as 'http://' + displayHost + ':' + port. If a user supplies an IPv6 alias (allowed by the current validation), the resulting URL will be invalid unless the host is wrapped in brackets (e.g., http://[::1]:8000). Also, allowing selectedAlias to be 0.0.0.0/:: bypasses the existing placeholder mapping and will render non-routable URLs. Consider normalizing/formatting the selected alias (IPv6 bracket wrapping + mapping/rejecting unspecified bind addresses) before using it in URL strings.

Suggested change
get displayHost() {
if (this.selectedAlias) return this.selectedAlias;
const host = this.stats.host || '127.0.0.1';
if (host === '0.0.0.0') return 'your-ip-address';
if (host === 'localhost') return '127.0.0.1';
return host;
formatDisplayHost(host) {
const value = (host || '').trim();
if (!value) return '127.0.0.1';
const unwrapped = value.startsWith('[') && value.endsWith(']')
? value.slice(1, -1)
: value;
if (unwrapped === '0.0.0.0' || unwrapped === '::') return 'your-ip-address';
if (unwrapped === 'localhost') return '127.0.0.1';
if (unwrapped.includes(':')) return `[${unwrapped}]`;
return unwrapped;
},
get displayHost() {
const host = this.selectedAlias || this.stats.host || '127.0.0.1';
return this.formatDisplayHost(host);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a7b2202. Refactored displayHost into a shared formatDisplayHost(host) helper that runs uniformly on both selectedAlias and the stats.host fallback. Handles bracket-wrapping IPv6 per RFC 3986, unwraps already-bracketed input so we don't double-wrap, and maps 0.0.0.0/:: to your-ip-address (defense in depth alongside the backend rejection in network.py). Verified all 10 edge cases (empty, localhost, 0.0.0.0, ::, ::1, fe80::1, [::1], IPv4, hostname, whitespace) map correctly.

seen: set[str] = set()
for alias in request.server_aliases:
if not isinstance(alias, str):
continue
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

server_aliases validation silently drops non-string entries (continue), which can make a request appear to succeed while losing user input. Since this is an API contract/validation path, it should return HTTP 400 when any entry is not a string (or otherwise invalid) so clients get deterministic feedback.

Suggested change
continue
raise HTTPException(
status_code=400,
detail="Invalid server alias: each alias must be a string",
)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a7b2202. Non-string entries now raise HTTPException(status_code=400, detail="Invalid server alias: each alias must be a string") instead of being silently dropped. Test coverage in tests/test_admin_server_aliases.py.

Comment on lines +33 to +36
"""Return True if ``value`` is a parseable IPv4 or IPv6 address."""
try:
ipaddress.ip_address(value)
return True
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_valid_ip() accepts unspecified bind addresses like 0.0.0.0 and ::, which are not usable as client-facing URL hosts but can be saved via server_aliases and then rendered by the dashboard. Consider treating these as invalid aliases (e.g., reject ip.is_unspecified, and potentially other non-routable categories as appropriate) to prevent the UI from presenting unusable URL hints.

Suggested change
"""Return True if ``value`` is a parseable IPv4 or IPv6 address."""
try:
ipaddress.ip_address(value)
return True
"""Return True if ``value`` is a usable IPv4 or IPv6 alias address."""
try:
ip = ipaddress.ip_address(value)
return not ip.is_unspecified

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a7b2202. is_valid_ip() now returns not ip.is_unspecified so 0.0.0.0 and :: are rejected at input time.

Also tightened is_valid_alias() to make IP validation authoritative when the input is IP-shaped. Without this guard, is_valid_alias("0.0.0.0") would still return True via the digit-only-label fallback in the hostname regex (each 0 matches ^[A-Za-z0-9-]{1,63}$). The new code path is: parse as IP first → if it parses, use is_valid_ip result → only fall through to is_valid_hostname for non-IP-shaped strings. The new test suite caught this — thanks for the nudge to add it.

Comment on lines +1688 to +1719
@router.get("/api/server-info")
async def get_server_info(is_admin: bool = Depends(require_admin)):
"""Return server connectivity metadata for the dashboard.

Provides the configured host, port, and the list of user-facing
aliases (hostnames/IPs) that the dashboard can use to render
selectable API URL hints.

Returns:
JSON object with ``host``, ``port``, and ``aliases``.

Raises:
HTTPException: 401 if not authenticated, 503 if server not initialized.
"""
from ..utils.network import detect_server_aliases

global_settings = _get_global_settings()
if global_settings is None:
raise HTTPException(status_code=503, detail="Server not initialized")

configured = list(global_settings.server.server_aliases)
if configured:
aliases = configured
else:
# Fall back to live detection if persisted list is empty.
aliases = detect_server_aliases(host=global_settings.server.host)

return {
"host": global_settings.server.host,
"port": global_settings.server.port,
"aliases": aliases,
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior is added for /admin/api/server-info and for server_aliases validation/persistence in update_global_settings, but there are no automated tests covering these code paths. The repository already has unit tests for admin route logic (e.g., tests/test_admin_api_key.py), so adding similar tests for: (1) returning persisted aliases, (2) fallback detection when empty, and (3) rejecting invalid aliases with 400 would help prevent regressions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a7b2202. Added tests/test_admin_server_aliases.py with 23 tests covering:

Network validation primitives — IPv4/IPv6 acceptance, unspecified address rejection, hostname rules, the IP-shape-as-hostname guard, non-string rejection.

Auto-detection guaranteesdetect_server_aliases always includes loopback when bound to localhost, never returns 0.0.0.0/::, output is deduped.

GET /admin/api/server-info — returns persisted aliases, falls back to live detection when empty, returns 503 when settings unavailable.

POST /admin/api/global-settings server_aliases save path — saves valid input, strips whitespace + dedupes, rejects invalid hostnames with 400, rejects 0.0.0.0/:: with 400, accepts ::1, empty list clears existing aliases.

Follows the tests/test_admin_reload.py pattern of monkey-patching admin_routes._get_global_settings and calling route functions directly via asyncio.run — same as the existing admin test conventions. All 23 pass; no regressions in the existing 57 admin tests.

Addresses 4 review comments on jundot#634:

1. Reject unspecified bind addresses in is_valid_ip (network.py)

   0.0.0.0 and :: parse as valid IPs but are bind addresses, not
   routable URL hosts. Reject them at input time so the dashboard
   never has to render an unusable URL hint.

   Also tightens is_valid_alias so an IP-shaped string can't slip
   through as a "valid hostname" via the digit-only-label fallback —
   if the value parses as an IP at all, the IP validity check is
   authoritative. Previously is_valid_alias("0.0.0.0") returned True
   because "0.0.0.0" matched the hostname regex after failing IP
   validation.

2. Return HTTP 400 on non-string alias entries (admin/routes.py)

   Silently dropping non-string entries via `continue` made the API
   contract opaque — clients could send malformed payloads and get
   a 200 with their bad data missing. Now raises HTTPException(400)
   with a clear message so clients get deterministic feedback.

3. Bracket-wrap IPv6 aliases in displayed URLs (dashboard.js)

   `'http://' + host + ':' + port` produces invalid URLs when host
   is an IPv6 address — the literal colons in `::1` collide with the
   port separator. Per RFC 3986, IPv6 in URLs must be wrapped in
   brackets: `http://[::1]:8000/v1`.

   Extracted formatDisplayHost(host) helper so the same normalization
   runs on both selectedAlias and the stats.host fallback. Handles:
   - empty → 127.0.0.1
   - existing brackets → unwrap then re-bracket consistently
   - 0.0.0.0 / :: → your-ip-address (defense in depth alongside jundot#1)
   - localhost → 127.0.0.1
   - any value containing ':' → bracket-wrap as IPv6

4. Add automated tests (tests/test_admin_server_aliases.py)

   23 new tests covering:
   - Network validation primitives (IPv4/IPv6, hostnames, unspecified
     rejection, IP-shape-as-hostname guard)
   - detect_server_aliases output guarantees (no unspecified, dedup)
   - GET /admin/api/server-info: persisted aliases, fallback to
     auto-detection, 503 when settings unavailable
   - POST /admin/api/global-settings server_aliases save path:
     valid save, whitespace strip + dedup, invalid hostname → 400,
     0.0.0.0 → 400, :: → 400, ::1 accepted, empty list clears

   Follows the existing tests/test_admin_reload.py pattern of
   monkey-patching admin_routes._get_global_settings and calling
   route functions directly via asyncio.run.
Copy link
Copy Markdown
Owner

@jundot jundot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid work, everything looks clean. I went through all 7 files and here's my quick summary:

What i like:

  • Alias detection chain is well thought out (localhost → hostname → mDNS → FQDN → psutil IPs, with getaddrinfo fallback)
  • The is_valid_alias guard against IP-shaped strings falling through to hostname validation is a nice catch
  • IPv6 bracket wrapping per RFC 3986, 0.0.0.0/:: rejection on both server and client side
  • 23 tests following the existing admin test patterns
  • No breaking changes, server_aliases defaults to [] and old configs just work

Minor suggestion (not blocking):

  • Could persist selectedAlias in localStorage so the dropdown selection survives page reloads

Merging, thanks for the contribution!

@jundot jundot merged commit a2ca277 into jundot:main Apr 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants