diff --git a/cmd/tidb-dashboard/main.go b/cmd/tidb-dashboard/main.go index 043f36d2d0..973333b266 100644 --- a/cmd/tidb-dashboard/main.go +++ b/cmd/tidb-dashboard/main.go @@ -273,16 +273,21 @@ func main() { defer s.Stop(context.Background()) //nolint:errcheck mux := http.DefaultServeMux - uiHandler := http.StripPrefix(strings.TrimRight(config.UIPathPrefix, "/"), uiserver.Handler(assets)) - mux.Handle("/", http.RedirectHandler(config.UIPathPrefix, http.StatusFound)) - mux.Handle(config.UIPathPrefix, uiHandler) - mux.Handle(config.APIPathPrefix, apiserver.Handler(s)) - mux.Handle(config.SwaggerPathPrefix, swaggerserver.Handler()) + uiPathPrefix := cliConfig.CoreConfig.UIPathPrefix() + apiPathPrefix := cliConfig.CoreConfig.APIPathPrefix() + swaggerPathPrefix := cliConfig.CoreConfig.SwaggerPathPrefix() + uiHandler := http.StripPrefix(strings.TrimRight(uiPathPrefix, "/"), uiserver.Handler(assets)) + if uiPathPrefix != "/" { + mux.Handle("/", http.RedirectHandler(uiPathPrefix, http.StatusFound)) + } + mux.Handle(uiPathPrefix, uiHandler) + mux.Handle(apiPathPrefix, apiserver.Handler(s)) + mux.Handle(swaggerPathPrefix, swaggerserver.Handler(cliConfig.CoreConfig)) log.Info(fmt.Sprintf("Dashboard server is listening at %s", listenAddr)) - log.Info(fmt.Sprintf("UI: http://%s/dashboard/", net.JoinHostPort(cliConfig.ListenHost, strconv.Itoa(cliConfig.ListenPort)))) - log.Info(fmt.Sprintf("API: http://%s/dashboard/api/", net.JoinHostPort(cliConfig.ListenHost, strconv.Itoa(cliConfig.ListenPort)))) - log.Info(fmt.Sprintf("Swagger: http://%s/dashboard/api/swagger/", net.JoinHostPort(cliConfig.ListenHost, strconv.Itoa(cliConfig.ListenPort)))) + log.Info(fmt.Sprintf("UI: http://%s%s", listenAddr, uiPathPrefix)) + log.Info(fmt.Sprintf("API: http://%s%s", listenAddr, apiPathPrefix)) + log.Info(fmt.Sprintf("Swagger: http://%s%s", listenAddr, swaggerPathPrefix)) srv := &http.Server{Handler: mux} // nolint:gosec var wg sync.WaitGroup diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index fbc4b6a543..f4d6ee5fe7 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -6,6 +6,7 @@ import ( "context" "io" "net/http" + "strings" "sync" "github.com/gin-contrib/gzip" @@ -270,14 +271,14 @@ func (s *Service) provideLocals() (*config.Config, http.FileSystem, *keyvisualre return s.config, s.uiAssetFS, s.customKeyVisualProvider } -func newAPIHandlerEngine() (apiHandlerEngine *gin.Engine, endpoint *gin.RouterGroup) { +func newAPIHandlerEngine(cfg *config.Config) (apiHandlerEngine *gin.Engine, endpoint *gin.RouterGroup) { apiHandlerEngine = gin.New() apiHandlerEngine.Use(gin.Recovery()) apiHandlerEngine.Use(cors.AllowAll()) apiHandlerEngine.Use(gzip.Gzip(gzip.DefaultCompression)) apiHandlerEngine.Use(rest.ErrorHandlerFn()) - endpoint = apiHandlerEngine.Group("/dashboard/api") + endpoint = apiHandlerEngine.Group(strings.TrimRight(cfg.APIPathPrefix(), "/")) return } diff --git a/pkg/apiserver/apiserver_test.go b/pkg/apiserver/apiserver_test.go new file mode 100644 index 0000000000..14aa09d2f4 --- /dev/null +++ b/pkg/apiserver/apiserver_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 PingCAP, Inc. Licensed under Apache-2.0. + +package apiserver + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/pingcap/tidb-dashboard/pkg/config" +) + +func TestNewAPIHandlerEngineUsesPublicPathPrefix(t *testing.T) { + cfg := config.Default() + cfg.PublicPathPrefix = "/test" + cfg.NormalizePublicPathPrefix() + + engine, endpoint := newAPIHandlerEngine(cfg) + endpoint.GET("/ping", func(c *gin.Context) { + c.String(http.StatusOK, "pong") + }) + + req := httptest.NewRequest(http.MethodGet, "/test/api/ping", nil) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /test/api/ping status = %d, want %d", rec.Code, http.StatusOK) + } + + req = httptest.NewRequest(http.MethodGet, "/dashboard/api/ping", nil) + rec = httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("GET /dashboard/api/ping status = %d, want %d", rec.Code, http.StatusNotFound) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 85b3d5df93..63e0b4b2ec 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,13 +12,7 @@ import ( "github.com/pingcap/tidb-dashboard/pkg/utils/version" ) -const ( - defaultPublicPathPrefix = "/dashboard" - - UIPathPrefix = "/dashboard/" - APIPathPrefix = "/dashboard/api/" - SwaggerPathPrefix = "/dashboard/api/swagger/" -) +const defaultPublicPathPrefix = "/dashboard" type Config struct { DataDir string @@ -82,6 +76,24 @@ func (c *Config) NormalizePDEndPoint() error { func (c *Config) NormalizePublicPathPrefix() { if c.PublicPathPrefix == "" { c.PublicPathPrefix = defaultPublicPathPrefix + return + } + trimmed := strings.Trim(c.PublicPathPrefix, "/") + if trimmed == "" { + c.PublicPathPrefix = "" + return } - c.PublicPathPrefix = strings.TrimRight(c.PublicPathPrefix, "/") + c.PublicPathPrefix = "/" + trimmed +} + +func (c *Config) UIPathPrefix() string { + return c.PublicPathPrefix + "/" +} + +func (c *Config) APIPathPrefix() string { + return c.PublicPathPrefix + "/api/" +} + +func (c *Config) SwaggerPathPrefix() string { + return c.PublicPathPrefix + "/api/swagger/" } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000000..8c2a6a08f1 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 PingCAP, Inc. Licensed under Apache-2.0. + +package config + +import "testing" + +func TestNormalizePublicPathPrefix(t *testing.T) { + tests := []struct { + name string + input string + prefix string + ui string + api string + swagger string + }{ + { + name: "empty uses default", + input: "", + prefix: "/dashboard", + ui: "/dashboard/", + api: "/dashboard/api/", + swagger: "/dashboard/api/swagger/", + }, + { + name: "custom with leading slash", + input: "/test", + prefix: "/test", + ui: "/test/", + api: "/test/api/", + swagger: "/test/api/swagger/", + }, + { + name: "custom with trailing slash", + input: "/test/", + prefix: "/test", + ui: "/test/", + api: "/test/api/", + swagger: "/test/api/swagger/", + }, + { + name: "custom without leading slash", + input: "test", + prefix: "/test", + ui: "/test/", + api: "/test/api/", + swagger: "/test/api/swagger/", + }, + { + name: "root prefix", + input: "/", + prefix: "", + ui: "/", + api: "/api/", + swagger: "/api/swagger/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{PublicPathPrefix: tt.input} + cfg.NormalizePublicPathPrefix() + + if cfg.PublicPathPrefix != tt.prefix { + t.Fatalf("PublicPathPrefix = %q, want %q", cfg.PublicPathPrefix, tt.prefix) + } + if cfg.UIPathPrefix() != tt.ui { + t.Fatalf("UIPathPrefix() = %q, want %q", cfg.UIPathPrefix(), tt.ui) + } + if cfg.APIPathPrefix() != tt.api { + t.Fatalf("APIPathPrefix() = %q, want %q", cfg.APIPathPrefix(), tt.api) + } + if cfg.SwaggerPathPrefix() != tt.swagger { + t.Fatalf("SwaggerPathPrefix() = %q, want %q", cfg.SwaggerPathPrefix(), tt.swagger) + } + }) + } +} diff --git a/pkg/swaggerserver/handler.go b/pkg/swaggerserver/handler.go index d4ae9d7768..f5dd7351f6 100644 --- a/pkg/swaggerserver/handler.go +++ b/pkg/swaggerserver/handler.go @@ -4,12 +4,15 @@ package swaggerserver import ( "net/http" + "strings" httpSwagger "github.com/swaggo/http-swagger" - // Swagger doc. - _ "github.com/pingcap/tidb-dashboard/swaggerspec" + + "github.com/pingcap/tidb-dashboard/pkg/config" + "github.com/pingcap/tidb-dashboard/swaggerspec" ) -func Handler() http.Handler { +func Handler(cfg *config.Config) http.Handler { + swaggerspec.SwaggerInfo_swagger.BasePath = strings.TrimRight(cfg.APIPathPrefix(), "/") return httpSwagger.Handler() } diff --git a/pkg/swaggerserver/handler_test.go b/pkg/swaggerserver/handler_test.go new file mode 100644 index 0000000000..6a7b47ba61 --- /dev/null +++ b/pkg/swaggerserver/handler_test.go @@ -0,0 +1,27 @@ +// Copyright 2026 PingCAP, Inc. Licensed under Apache-2.0. + +package swaggerserver + +import ( + "testing" + + "github.com/pingcap/tidb-dashboard/pkg/config" + "github.com/pingcap/tidb-dashboard/swaggerspec" +) + +func TestHandlerUpdatesSwaggerBasePath(t *testing.T) { + basePath := swaggerspec.SwaggerInfo_swagger.BasePath + t.Cleanup(func() { + swaggerspec.SwaggerInfo_swagger.BasePath = basePath + }) + + cfg := config.Default() + cfg.PublicPathPrefix = "/test" + cfg.NormalizePublicPathPrefix() + + Handler(cfg) + + if swaggerspec.SwaggerInfo_swagger.BasePath != "/test/api" { + t.Fatalf("basePath = %q, want %q", swaggerspec.SwaggerInfo_swagger.BasePath, "/test/api") + } +} diff --git a/ui/packages/tidb-dashboard-for-op/src/utils/publicPathPrefix.ts b/ui/packages/tidb-dashboard-for-op/src/utils/publicPathPrefix.ts index b12e7f1b3d..1eeb923465 100644 --- a/ui/packages/tidb-dashboard-for-op/src/utils/publicPathPrefix.ts +++ b/ui/packages/tidb-dashboard-for-op/src/utils/publicPathPrefix.ts @@ -1,9 +1,8 @@ const DEF_PUBLIC_PATH_PREFIX = '/dashboard' -let prefix = - document - .querySelector('meta[name="x-public-path-prefix"]') - ?.getAttribute('content') || DEF_PUBLIC_PATH_PREFIX +let prefix = document + .querySelector('meta[name="x-public-path-prefix"]') + ?.getAttribute('content') ?? DEF_PUBLIC_PATH_PREFIX if (prefix === '__PUBLIC_PATH_PREFIX__') { prefix = DEF_PUBLIC_PATH_PREFIX