feat(dashboard): support serving under URL prefix via X-Forwarded-Prefix

The Hermes dashboard previously assumed it was served at the root of its
host (e.g. https://kanban.tilos.com/). When mounted behind a path-prefix
reverse proxy (e.g. https://mission-control.tilos.com/hermes/), the SPA
404'd because:

- index.html shipped absolute /assets/index-*.js URLs
- React Router had no basename
- The plugin loader hit /dashboard-plugins/<name>/... at the root host
- CSS in the bundle had absolute url(/fonts/...) references

This patch makes the dashboard prefix-aware at runtime, no rebuild
required. The proxy injects 'X-Forwarded-Prefix: /hermes' on every
request and the Python server:

- Rewrites href/src in served index.html to '${prefix}/assets/...'
- Injects 'window.__HERMES_BASE_PATH__="${prefix}"' for the SPA to read
- Rewrites url() refs in CSS at serve time

The SPA reads window.__HERMES_BASE_PATH__ once at boot and:

- Prefixes all /api/... fetches via api.ts
- Prefixes all /dashboard-plugins/... script/css URLs in usePlugins
- Sets <BrowserRouter basename={...}> so client-side routing works

When no X-Forwarded-Prefix header is present, behavior is unchanged
(empty prefix => serves at root, kanban.tilos.com keeps working).

Refs: MC-AUTO-13
This commit is contained in:
cmcgrabby-hue 2026-05-03 18:19:50 -07:00 committed by Teknium
parent 6769060ae2
commit 52e2777821
4 changed files with 98 additions and 11 deletions

View file

@ -1,4 +1,21 @@
const BASE = "";
// The dashboard can be served either at the root of its host (e.g.
// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied
// (e.g. https://mission-control.tilos.com/hermes/). The Python backend
// injects ``window.__HERMES_BASE_PATH__`` into index.html based on the
// incoming ``X-Forwarded-Prefix`` header so the SPA can address its own
// ``/api/...`` and ``/dashboard-plugins/...`` URLs correctly without a
// rebuild. Empty string means "served at root".
function readBasePath(): string {
if (typeof window === "undefined") return "";
const raw = window.__HERMES_BASE_PATH__ ?? "";
if (!raw) return "";
// Normalise: ensure leading slash, strip trailing slash.
const withLead = raw.startsWith("/") ? raw : `/${raw}`;
return withLead.replace(/\/+$/, "");
}
export const HERMES_BASE_PATH = readBasePath();
const BASE = HERMES_BASE_PATH;
import type { DashboardTheme } from "@/themes/types";
@ -7,6 +24,7 @@ import type { DashboardTheme } from "@/themes/types";
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_BASE_PATH__?: string;
}
}
let _sessionToken: string | null = null;