Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ HAMi-WebUI is built upon the [HAMi](https://github.com/Project-HAMi/HAMi) open-s
## Get started

- [Installation guide](docs/installation/helm/index.md)
- [Serving under a URL sub-path (base path)](docs/installation/sub-path.md)

## Contributing

Expand Down
42 changes: 41 additions & 1 deletion charts/hami-webui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The command removes all the Kubernetes components associated with the chart and
| Key | Type | Default | Description |
|-----|------|------------------------------------------------------------------------------------|-------------|
| affinity | object | `{}` | |
| basePath | string | `"/"` | URL sub-path to serve the WebUI under, injected as `HAMI_WEBUI_BASE_PATH`. `"/"` = root (default). Set e.g. `"/gpu-ui/"` for a non-stripping reverse proxy. Resolved at request time (no image rebuild). A path-stripping proxy that sets `X-Forwarded-Prefix` works without this. |
| dcgm-exporter.enabled | bool | `true` | |
| dcgm-exporter.nodeSelector.gpu | string | `"on"` | |
| dcgm-exporter.serviceMonitor.additionalLabels.jobRelease | string | `"hami-webui-prometheus"` | |
Expand Down Expand Up @@ -163,7 +164,46 @@ kube-prometheus-stack:
```
This allows you to reuse the existing Operator and CRDs while deploying a new Prometheus instance.

### 3. About `jobRelease` Labels
### 3. Serving under a URL sub-path (base path)

By default the WebUI is served at the site root (`/`). To serve it behind a
reverse-proxy prefix such as `https://host/gpu-ui/` — without rebuilding the
frontend image — set the base path. It is resolved at **request time**, so the
same image works at any path.

There are two supported reverse-proxy modes:

**Mode A — path-stripping proxy (recommended, zero chart config).**
If your proxy strips the prefix before forwarding and sets the
`X-Forwarded-Prefix` header (nginx `proxy_set_header X-Forwarded-Prefix /gpu-ui;`,
Traefik `stripPrefix` + headers middleware, etc.), the WebUI picks the prefix up
from the header automatically. Leave `basePath` at its default `/`.

**Mode B — proxy passes the full prefixed path through (no stripping).**
Set the chart value so the BFF knows its own prefix:

```yaml
basePath: "/gpu-ui/"
# If you also expose it through this chart's ingress, point the path at the same prefix:
ingress:
enabled: true
hosts:
- host: your-host
paths:
- path: /gpu-ui
pathType: Prefix
```

`basePath` is injected into the frontend BFF container as the
`HAMI_WEBUI_BASE_PATH` environment variable. Values are normalized to a
leading/trailing-slash form (`gpu-ui` → `/gpu-ui/`); `/` (the default) means
root serving and preserves the historical behaviour exactly. The header (Mode A)
takes precedence over the env var when both are present.

No changes are needed on the Go API backend — it is always reached via the BFF's
`/api/vgpu` proxy.

### 4. About `jobRelease` Labels

If deploying a completely new Prometheus, you can leave the default `jobRelease: hami-webui-prometheus` unchanged.

Expand Down
2 changes: 2 additions & 0 deletions charts/hami-webui/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ spec:
imagePullPolicy: {{ .Values.image.frontend.pullPolicy }}
env:
{{- toYaml .Values.env.frontend | nindent 12 }}
- name: HAMI_WEBUI_BASE_PATH
value: {{ .Values.basePath | default "/" | quote }}
ports:
- name: http
containerPort: 3000
Expand Down
11 changes: 11 additions & 0 deletions charts/hami-webui/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ securityContext: {}
# runAsNonRoot: true
# runAsUser: 1000

# URL sub-path (base path) the WebUI is served under. "/" (default) serves at
# the site root and preserves the historical behaviour exactly. Set to a prefix
# such as "/gpu-ui/" to serve the WebUI behind a reverse proxy that passes the
# full prefixed path through (i.e. does NOT strip it). It is injected into the
# frontend BFF as the HAMI_WEBUI_BASE_PATH env var and resolved at request time,
# so changing it does NOT require rebuilding the frontend image. A path-stripping
# proxy that sets the X-Forwarded-Prefix header works without setting this.
# If you set a non-root basePath, remember to update ingress.hosts[].paths[].path
# to the same prefix.
basePath: "/"

service:
type: ClusterIP
port: 3000
Expand Down
141 changes: 141 additions & 0 deletions docs/installation/sub-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Serving HAMi-WebUI under a URL sub-path

By default HAMi-WebUI is served at the site root (`https://host/`). It can also
be served under an arbitrary reverse-proxy prefix (a "sub-path" / "base path"),
e.g. `https://host/gpu-ui/`, so several tools can share one hostname.

The base path is resolved **at request time**, not baked into the frontend
bundle — so a single `*-fe-oss` image works at any path and changing the path
never requires an image rebuild. The design mirrors Grafana's
`serve_from_sub_path` and ArgoCD's `server.rootpath`.

The Go API backend (`*-be-oss`) is unaffected — it is always reached through the
BFF's `/api/vgpu` proxy and is path-agnostic.

## How the base path is resolved

The frontend BFF (the NestJS `*-fe-oss` container) resolves the prefix from two
sources, in order of precedence:

1. **`X-Forwarded-Prefix` request header** — set by a path-stripping reverse
proxy. Takes precedence when present.
2. **`HAMI_WEBUI_BASE_PATH` environment variable** — the deploy-time default.
Defaults to `/` (root serving, the historical behaviour).

Values are normalized to a leading/trailing-slash form: `gpu-ui`, `/gpu-ui`, and
`/gpu-ui/` all become `/gpu-ui/`. `/` means root.

On every request for `index.html` the BFF injects the resolved value as:

```html
<base href="/gpu-ui/">
<script>window.__BASE_PATH__="/gpu-ui/"</script>
```

The SPA reads `window.__BASE_PATH__` for its router base, its axios `baseURL`,
and its socket.io `path`; the `<base>` tag makes the relative asset URLs
(`./static/…`, `./favicon.svg`) resolve under the prefix.

## Two supported proxy modes

### Mode A — path-stripping proxy (sets `X-Forwarded-Prefix`)

The proxy strips the prefix before forwarding to the BFF and advertises it via
the header. Nothing needs to be configured on the chart — leave `basePath` at
`/`.

nginx example:

```nginx
location /gpu-ui/ {
proxy_pass http://hami-webui:3000/; # trailing slash strips /gpu-ui/
proxy_set_header X-Forwarded-Prefix /gpu-ui;
proxy_set_header Host $host;
# socket.io upgrade (optional, for real-time views)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```

### Mode B — proxy forwards the full prefixed path (no stripping)

The BFF receives `/gpu-ui/...` and must know its own prefix, so set the chart
value (or the env var directly):

```yaml
# values.yaml
basePath: "/gpu-ui/"
ingress:
enabled: true
hosts:
- host: your-host
paths:
- path: /gpu-ui
pathType: Prefix
```

The BFF strips the configured prefix from incoming URLs up-front, so static
assets, the `/api/vgpu` proxy and the SPA deep-link fallback all keep working.

## Helm value

| Value | Default | Description |
|------------|---------|-----------------------------------------------------------------------------|
| `basePath` | `"/"` | Sub-path to serve under. Injected as `HAMI_WEBUI_BASE_PATH`. `"/"` = root. |

---

## Manual test plan

Prerequisites: a running cluster (or local `node dist/main`) with the built
frontend in `public/`. Replace `HOST` accordingly.

### 1. Root serving — no regression (default)

Deploy with defaults (`basePath: "/"`, no header).

| Check | Expect |
|-------|--------|
| `GET /` | 200; HTML contains `<base href="/">` and `window.__BASE_PATH__="/"` |
| Deep-link refresh `GET /admin/vgpu/monitor/overview` | 200; same injected `<base href="/">` |
| `GET /static/<asset>.js` | 200 |
| `GET /favicon.svg` | 200 |
| `GET /api/vgpu/v1/summary` (POST from UI) | proxied to the Go backend |
| `GET /health_check` | `{"code":0,...,"data":"OK"}` |
| Browser: open `/`, navigate the app, open a real-time view | assets load, REST works, socket.io connects on `/socket.io` |

### 2. Mode B — non-stripping proxy under `/gpu-ui/`

Deploy with `basePath: "/gpu-ui/"` and an ingress path of `/gpu-ui`.

| Check | Expect |
|-------|--------|
| `GET /gpu-ui/` and `GET /gpu-ui` | 200; `<base href="/gpu-ui/">`, `window.__BASE_PATH__="/gpu-ui/"` |
| Deep-link refresh `GET /gpu-ui/admin/vgpu/monitor/overview` | 200; injected base `/gpu-ui/` |
| `GET /gpu-ui/static/<asset>.js` | 200 |
| `GET /gpu-ui/favicon.svg` | 200 |
| UI REST calls | requested at `/gpu-ui/api/vgpu/...`, proxied to the backend |
| Real-time view | socket.io connects at `/gpu-ui/socket.io` |
| `GET /health_check` (unprefixed k8s probe) | still `...,"data":"OK"` |

### 3. Mode A — path-stripping proxy via `X-Forwarded-Prefix`

Deploy with defaults (`basePath: "/"`) behind a proxy that strips `/gpu-ui` and
sets `X-Forwarded-Prefix: /gpu-ui`.

| Check | Expect |
|-------|--------|
| `curl -H 'X-Forwarded-Prefix: /gpu-ui' https://HOST/` | `<base href="/gpu-ui/">`, `window.__BASE_PATH__="/gpu-ui/"` |
| Deep-link (proxy has already stripped path) `curl -H 'X-Forwarded-Prefix: /gpu-ui' https://HOST/admin/...` | injected base `/gpu-ui/` |
| Through the browser at `https://HOST/gpu-ui/` | page loads, assets/REST/socket.io all resolve under `/gpu-ui/` |

### 4. Security

| Check | Expect |
|-------|--------|
| `curl -H 'X-Forwarded-Prefix: /x"><script>alert(1)</script>' https://HOST/` | injected `<base>` is sanitized (no `"`, `<`, `>`); no script injection |

A scripted version of checks 1–3 (and 4) is exercised by the BFF unit tests in
`src/utils/base-path.spec.ts` and by running `node dist/main` with
`HAMI_WEBUI_BASE_PATH` / `X-Forwarded-Prefix` against a `public/` build.
11 changes: 8 additions & 3 deletions packages/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.svg">
<base href="/"/>
<!--
No hard-coded base-href tag here on purpose. The BFF injects one at request
time (see src/app.controller.ts) so the same build serves both the site
root and an arbitrary reverse-proxy sub-path. Asset/favicon refs are
relative (./…) so they resolve against that injected base.
-->
<link rel="icon" href="./favicon.svg">
<title>HAMi</title>
</head>
<body>
Expand All @@ -14,6 +19,6 @@
</noscript>

<div id="app"></div>
<script type="module" src="/src/entry-vite.js"></script>
<script type="module" src="./src/entry-vite.js"></script>
</body>
</html>
7 changes: 6 additions & 1 deletion packages/web/src/components/Socket/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { io } from 'socket.io-client';
import { getBasePath } from '@/utils/base-path';

class WS {
static instance;
socket;

constructor(url) {
this.socket = io(url);
// socket.io's default endpoint is the root-absolute "/socket.io". Under a
// sub-path the reverse proxy forwards "{basePath}socket.io", so set the
// client path accordingly. At the site root this is exactly "/socket.io".
const path = `${getBasePath()}socket.io`;
this.socket = io(url, { path });

this.socket.on('connect', () => {
});
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/router/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router';
import { pick, uniqBy } from 'lodash';
import { getBasePath } from '@/utils/base-path';
/* Layout */
import Layout from '@/layout';
/* Router Modules */
Expand Down Expand Up @@ -50,7 +51,9 @@ export const asyncRoutes = [
];

const router = createRouter({
history: createWebHistory(),
// Base the HTML5 history on the runtime-injected sub-path so deep-link
// refreshes resolve under the prefix (defaults to "/" for root serving).
history: createWebHistory(getBasePath()),
routes: constantRoutes,
});

Expand Down
25 changes: 25 additions & 0 deletions packages/web/src/utils/base-path.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Runtime URL sub-path (base path) helpers for the SPA.
*
* The BFF injects `window.__BASE_PATH__` into index.html at request time
* (e.g. "/" for root serving, "/gpu-ui/" behind a reverse-proxy prefix).
* Everything that builds an absolute-ish URL — the vue-router history base,
* the axios baseURL, the socket.io path — derives it from here so a single
* frontend build works under any prefix without a rebuild.
*
* In the Vite dev server (where the BFF does not serve index.html) the global
* is undefined and we fall back to root, matching the dev proxy setup.
*/

/** Canonical base path with leading + trailing slash, e.g. "/" or "/gpu-ui/". */
export function getBasePath() {
const runtime =
typeof window !== 'undefined' ? window.__BASE_PATH__ : undefined;
if (!runtime) {
return '/';
}
let p = String(runtime).trim();
if (!p.startsWith('/')) p = '/' + p;
if (!p.endsWith('/')) p = p + '/';
return p;
}
9 changes: 8 additions & 1 deletion packages/web/src/utils/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import axios from 'axios';

import { ElMessage, ElMessageBox, ElNotification } from 'element-plus';
import i18n from '@/locales';
import { getBasePath } from '@/utils/base-path';

// Default request timeout in ms. Override at build time via VUE_APP_REQUEST_TIMEOUT
// (injected through .env.* or chart values.frontend.requestTimeout). 60s is large
Expand All @@ -11,8 +12,14 @@ const DEFAULT_REQUEST_TIMEOUT = 60000;
const requestTimeout =
Number.parseInt(process.env.VUE_APP_REQUEST_TIMEOUT, 10) || DEFAULT_REQUEST_TIMEOUT;

// API request URLs are root-absolute ("/api/vgpu/..."). Using the runtime base
// path as the axios baseURL makes them resolve under the sub-path (e.g.
// "/gpu-ui/api/vgpu/...") without depending on the injected <base> tag. At the
// site root — and in the Vite dev server, where window.__BASE_PATH__ is not
// injected — getBasePath() returns "/", giving the historical "/api/vgpu/..."
// exactly (superseding the build-time VUE_APP_BASE_API).
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
baseURL: getBasePath(), // url = base url + request url
timeout: requestTimeout,
validateStatus: function (status) {
return (status >= 200 && status < 300) || status > 520;
Expand Down
25 changes: 24 additions & 1 deletion src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ import { Controller, Get, Req, Res } from '@nestjs/common'
import { AppService } from './app.service'
import { Request, Response } from 'express'
import { join } from 'path'
import { readFileSync } from 'fs'
import { injectBasePath, resolveBasePath } from './utils/base-path'

@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

// index.html is read from disk once and cached; the per-request base path is
// injected on every send, so the same built artifact serves any sub-path.
private indexTemplate: string | null = null

private readonly indexPath = join(__dirname, '..', 'public', 'index.html')

@Get('health_check')
healthCheck(): string {
return this.appService.healthCheck()
Expand All @@ -15,6 +23,21 @@ export class AppController {
// api 透传到后api-proxy,health_check, bff 被node接管,其他的直接打回前端vue路由
@Get('*')
index(@Req() req: Request, @Res() res: Response) {
return res.sendFile(join(__dirname, '..', 'public', 'index.html'))
const basePath = resolveBasePath(req)
// The injected <base href>/window.__BASE_PATH__ depend on X-Forwarded-Prefix,
// so the response body varies by that header. Advertise it via Vary so any
// CDN/intermediate proxy caches responses per-prefix instead of serving one
// prefix's index.html to everyone (cache-poisoning / broken-routing risk).
res.setHeader('Vary', 'X-Forwarded-Prefix')
res.type('html').send(this.renderIndex(basePath))
}
Comment thread
tittuvarghese marked this conversation as resolved.

// Return index.html with the resolved base path injected at request time
// (see injectBasePath). The template is read from disk once and cached.
private renderIndex(basePath: string): string {
if (this.indexTemplate === null) {
this.indexTemplate = readFileSync(this.indexPath, 'utf-8')
}
return injectBasePath(this.indexTemplate, basePath)
}
}
Loading