diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cefe1c7b..30c41a16 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -98,6 +98,17 @@ jobs: build-args: VER=${{ needs.pre_release.outputs.release_tag }} platforms: linux/amd64,linux/arm64 + - name: Build and push with base path + uses: docker/build-push-action@v4 + if: github.event_name != 'pull_request' + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: komodorio/helm-dashboard:${{ needs.pre_release.outputs.release_tag }}-basepath,komodorio/helm-dashboard:latest-basepath + labels: ${{ steps.meta.outputs.labels }} + build-args: VER=${{ needs.pre_release.outputs.release_tag }},VITE_BASE_PATH=/helm-dashboard + platforms: linux/amd64,linux/arm64 + publish_chart: runs-on: ubuntu-latest needs: [ image, pre_release ] diff --git a/Dockerfile b/Dockerfile index b0316859..11968d50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ FROM node:latest as frontend WORKDIR /build COPY frontend ./ +ARG VITE_BASE_PATH RUN npm i && npm run build @@ -49,4 +50,4 @@ COPY --from=builder /build/src/bin/dashboard /bin/helm-dashboard ENTRYPOINT ["/bin/helm-dashboard", "--no-browser", "--bind=0.0.0.0", "--port=8080"] -# docker build . -t komodorio/helm-dashboard:0.0.0 && kind load docker-image komodorio/helm-dashboard:0.0.0 \ No newline at end of file +# docker build . -t komodorio/helm-dashboard:0.0.0 && kind load docker-image komodorio/helm-dashboard:0.0.0 diff --git a/Makefile b/Makefile index f5419b5d..8d771f33 100644 --- a/Makefile +++ b/Makefile @@ -14,18 +14,18 @@ pull: ; $(info $(M) Pulling source...) @ @git pull .PHONY: build_go -build_go: $(BIN) ; $(info $(M) Building GO...) @ ## Build program binary +build_go: $(BIN) ; $(info $(M) Building GO...) @ ## Build program binary go build \ -ldflags '-X main.version=$(VERSION) -X main.buildDate=$(DATE)' \ - -o bin/dashboard . + -o bin/dashboard . ; .PHONY: build_ui build_ui: $(BIN) ; $(info $(M) Building UI...) @ ## Build program binary - cd frontend && npm i && npm run build && cd .. + cd frontend && npm i && npm run build && cd .. ; .PHONY: build build: build_ui build_go ; $(info $(M) Building executable...) @ ## Build program binary .PHONY: debug debug: ; $(info $(M) Running dashboard in debug mode...) @ - @DEBUG=1 ./bin/dashboard \ No newline at end of file + @DEBUG=1 ./bin/dashboard diff --git a/charts/helm-dashboard/templates/_helpers.tpl b/charts/helm-dashboard/templates/_helpers.tpl index 5e2ced80..f797987a 100644 --- a/charts/helm-dashboard/templates/_helpers.tpl +++ b/charts/helm-dashboard/templates/_helpers.tpl @@ -64,6 +64,9 @@ Return the proper image name {{- define "helm-dashboard.image" -}} {{- $image := .Values.image -}} {{- $tag := default .Chart.AppVersion $image.tag -}} +{{- if .Values.image.basePath }} + {{- $tag = printf "%s%s" $tag "-basepath" -}} +{{- end -}} {{- $_ := set $image "tag" $tag -}} {{ include "common.images.image" (dict "imageRoot" $_ "global" .Values.global) }} {{- end -}} @@ -73,4 +76,4 @@ Return the proper image name */}} {{- define "test.image" -}} {{ include "common.images.image" (dict "imageRoot" .Values.testImage "global" .Values.global) }} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/charts/helm-dashboard/templates/deployment.yaml b/charts/helm-dashboard/templates/deployment.yaml index 11151083..d443acc0 100644 --- a/charts/helm-dashboard/templates/deployment.yaml +++ b/charts/helm-dashboard/templates/deployment.yaml @@ -46,6 +46,10 @@ spec: value: /opt/dashboard/helm/data - name: DEBUG value: {{- ternary " '1'" "" .Values.debug }} + {{- if .Values.image.basePath }} + - name: HD_BASE_PATH + value: /helm-dashboard + {{end}} {{- if .Values.dashboard.namespace }} - name: HELM_NAMESPACE value: {{ .Values.dashboard.namespace }} diff --git a/charts/helm-dashboard/values.yaml b/charts/helm-dashboard/values.yaml index b9759894..3813bc7f 100644 --- a/charts/helm-dashboard/values.yaml +++ b/charts/helm-dashboard/values.yaml @@ -20,6 +20,8 @@ image: # Specifies the exact image digest to pull. digest: "" imagePullSecrets: [] + # Flag for using an image with fixed base path /helm-dashboard + basePath: false nameOverride: "" fullnameOverride: "" diff --git a/frontend/src/API/apiService.ts b/frontend/src/API/apiService.ts index 2343d3d0..7d8ffd33 100644 --- a/frontend/src/API/apiService.ts +++ b/frontend/src/API/apiService.ts @@ -16,7 +16,18 @@ interface ClustersResponse { } class ApiService { currentCluster = ""; - constructor(protected readonly isMockMode: boolean = false) {} + private readonly basePath: string; + constructor(protected readonly isMockMode: boolean = false) { + const fromEnv = (import.meta as any).env?.VITE_BASE_PATH as string | undefined; + // In production, Vite injects import.meta.env.BASE_URL. Prefer explicit VITE_BASE_PATH if provided. + const viteBase = (import.meta as any).env?.BASE_URL as string | undefined; + const computed = (fromEnv ?? viteBase ?? "/").trim(); + // Normalize to leading slash, no trailing slash (except root) + let normalized = computed; + if (!normalized.startsWith("/")) normalized = "/" + normalized; + if (normalized !== "/") normalized = normalized.replace(/\/+$/g, ""); + this.basePath = normalized; + } setCluster = (cluster: string) => { this.currentCluster = cluster; @@ -28,14 +39,20 @@ class ApiService { ): Promise { let response; + const isAbsolute = /^https?:\/\//.test(url); + const fullUrl = isAbsolute + ? url + : this.basePath === "/" + ? url + : `${this.basePath}${url.startsWith("/") ? url : `/${url}`}`; if (this.currentCluster) { const headers = new Headers(options?.headers); if (!headers.has("X-Kubecontext")) { headers.set("X-Kubecontext", this.currentCluster); } - response = await fetch(url, { ...options, headers }); + response = await fetch(fullUrl, { ...options, headers }); } else { - response = await fetch(url, options); + response = await fetch(fullUrl, options); } if (!response.ok) { @@ -55,7 +72,7 @@ class ApiService { } getToolVersion = async () => { - const response = await fetch("/status"); + const response = await this.fetchWithDefaults("/status"); const data = await response.json(); return data; }; @@ -73,8 +90,9 @@ class ApiService { }; getClusters = async () => { - const response = await fetch("/api/k8s/contexts"); - const data = (await response.json()) as ClustersResponse[]; + const data = await this.fetchWithDefaults( + "/api/k8s/contexts" + ); return data; }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9cf16ea6..5c38c37d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const port = env.VITE_SERVER_PORT || 8080; return { + base: env.VITE_BASE_PATH || "/", plugins: [ react(), viteStaticCopy({ @@ -31,12 +32,25 @@ export default defineConfig(({ mode }) => { emptyOutDir: true, }, server: { - proxy: { - "^/api/.*": `http://127.0.0.1:${port}`, - "^/status*": `http://127.0.0.1:${port}`, - "^/diff*": `http://127.0.0.1:${port}`, - "^/static*": `http://127.0.0.1:${port}`, - }, + proxy: (() => { + const base = (env.VITE_BASE_PATH || "/").replace(/\/$/, ""); + const target = `http://127.0.0.1:${port}`; + // Support both with and without base for local dev convenience + return { + "^/api/.*": target, + "^/status*": target, + "^/diff*": target, + "^/static*": target, + ...(base + ? { + [`^${base}/api/.*`]: target, + [`^${base}/status*`]: target, + [`^${base}/diff*`]: target, + [`^${base}/static*`]: target, + } + : {}), + } as Record; + })(), }, }; }); diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go index e4c753b2..05425c4e 100644 --- a/pkg/dashboard/api.go +++ b/pkg/dashboard/api.go @@ -4,7 +4,9 @@ import ( "context" "html" "net/http" + "os" "path" + "strings" "github.com/gin-gonic/gin" "github.com/komodorio/helm-dashboard/v2/pkg/dashboard/handlers" @@ -99,13 +101,27 @@ func NewRouter(abortWeb context.CancelFunc, data *objects.DataLayer, debug bool) api.Use(allowCORS) } - configureStatic(api) - configureRoutes(abortWeb, data, api) + // Determine base path prefix for mounting the app under a subpath + basePath := strings.TrimSpace(os.Getenv("HD_BASE_PATH")) + if basePath == "/" { + basePath = "" + } + if basePath != "" { + if !strings.HasPrefix(basePath, "/") { + basePath = "/" + basePath + } + basePath = strings.TrimRight(basePath, "/") + } + + root := api.Group(basePath) + + configureStatic(root) + configureRoutes(abortWeb, data, root) return api } -func configureRoutes(abortWeb context.CancelFunc, data *objects.DataLayer, api *gin.Engine) { +func configureRoutes(abortWeb context.CancelFunc, data *objects.DataLayer, api *gin.RouterGroup) { // server shutdown handler api.DELETE("/", func(c *gin.Context) { abortWeb() @@ -188,7 +204,7 @@ func configureKubectls(api *gin.RouterGroup, data *objects.DataLayer) { api.GET("/:kind/list", h.GetNameSpaces) } -func configureStatic(api *gin.Engine) { +func configureStatic(api *gin.RouterGroup) { fs := http.FS(frontend.StaticFS) api.GET("/", func(c *gin.Context) { @@ -196,10 +212,12 @@ func configureStatic(api *gin.Engine) { }) api.GET("/assets/*filepath", func(c *gin.Context) { - c.FileFromFS(path.Join("dist", c.Request.URL.Path), fs) + fp := strings.TrimPrefix(c.Param("filepath"), "/") + c.FileFromFS(path.Join("dist", "assets", fp), fs) }) api.GET("/static/*filepath", func(c *gin.Context) { - c.FileFromFS(path.Join("dist", c.Request.URL.Path), fs) + fp := strings.TrimPrefix(c.Param("filepath"), "/") + c.FileFromFS(path.Join("dist", "static", fp), fs) }) } diff --git a/pkg/dashboard/api_test.go b/pkg/dashboard/api_test.go index 9dbf62d8..76eba1e5 100644 --- a/pkg/dashboard/api_test.go +++ b/pkg/dashboard/api_test.go @@ -89,9 +89,10 @@ func TestConfigureStatic(t *testing.T) { // Create an API Engine api := gin.Default() + root := api.Group("/") // Configure static routes - configureStatic(api) + configureStatic(root) // Start the server api.ServeHTTP(w, req) @@ -109,6 +110,7 @@ func TestConfigureRoutes(t *testing.T) { // Create a API Engine api := gin.Default() + root := api.Group("/") // Required arguments for route configuration abortWeb := func() {} @@ -119,7 +121,7 @@ func TestConfigureRoutes(t *testing.T) { } // Configure routes to API engine - configureRoutes(abortWeb, data, api) + configureRoutes(abortWeb, data, root) // Start the server api.ServeHTTP(w, req) diff --git a/pkg/dashboard/server.go b/pkg/dashboard/server.go index 068bdd1f..f5403d92 100644 --- a/pkg/dashboard/server.go +++ b/pkg/dashboard/server.go @@ -52,7 +52,14 @@ func (s *Server) StartServer(ctx context.Context, cancel context.CancelFunc) (st api := NewRouter(cancel, data, s.Debug) done := s.startBackgroundServer(api, ctx) - return "http://" + s.Address, done, nil + basePath := strings.TrimSpace(os.Getenv("HD_BASE_PATH")) + if basePath == "/" { + basePath = "" + } + if basePath != "" && !strings.HasPrefix(basePath, "/") { + basePath = "/" + basePath + } + return "http://" + s.Address + basePath + "/", done, nil } func (s *Server) detectClusterMode(data *objects.DataLayer) error {