diff --git a/internal/config/config.go b/internal/config/config.go index 41050df1e..ce8d83a12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,7 +58,8 @@ type Config struct { // Deprecated: Use UI.NavbarTitle instead NavbarTitle string `mapstructure:"navbarTitle"` // Deprecated: Use UI.MaxDashboardPageLimit instead - MaxDashboardPageLimit int `mapstructure:"maxDashboardPageLimit"` + MaxDashboardPageLimit int `mapstructure:"maxDashboardPageLimit"` + Headless bool `mapstructure:"headless"` // Legacy fields for backward compatibility - End // Other settings @@ -112,6 +113,7 @@ type UI struct { NavbarColor string `mapstructure:"navbarColor"` NavbarTitle string `mapstructure:"navbarTitle"` MaxDashboardPageLimit int `mapstructure:"maxDashboardPageLimit"` + Headless bool `mapstructure:"headless"` } // RemoteNode represents a remote node configuration @@ -206,6 +208,9 @@ func (c *Config) migrateUISettings() { if c.MaxDashboardPageLimit > 0 { c.UI.MaxDashboardPageLimit = c.MaxDashboardPageLimit } + if c.Headless { + c.UI.Headless = c.Headless + } } func (c *Config) cleanBasePath() { diff --git a/internal/config/loader.go b/internal/config/loader.go index ebe096149..802ed5945 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -149,6 +149,7 @@ func (l *ConfigLoader) bindEnvironmentVariables() { l.bindEnv("ui.logEncodingCharset", "UI_LOG_ENCODING_CHARSET") l.bindEnv("ui.navbarColor", "UI_NAVBAR_COLOR") l.bindEnv("ui.navbarTitle", "UI_NAVBAR_TITLE") + l.bindEnv("ui.headless", "UI_HEADLESS") // UI configurations (legacy) l.bindEnv("ui.maxDashboardPageLimit", "MAX_DASHBOARD_PAGE_LIMIT") diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go index 8a490c324..05314ea2f 100644 --- a/internal/frontend/frontend.go +++ b/internal/frontend/frontend.go @@ -36,6 +36,7 @@ func New(cfg *config.Config, cli client.Client) *server.Server { APIBaseURL: cfg.APIBaseURL, TimeZone: cfg.TZ, RemoteNodes: remoteNodes, + Headless: cfg.UI.Headless, } if cfg.Auth.Token.Enabled { diff --git a/internal/frontend/server/routes.go b/internal/frontend/server/routes.go index 18fa71c06..94097dc5a 100644 --- a/internal/frontend/server/routes.go +++ b/internal/frontend/server/routes.go @@ -4,11 +4,23 @@ import ( "context" "net/http" + "github.com/dagu-org/dagu/internal/logger" "github.com/go-chi/chi/v5" ) func (svr *Server) defaultRoutes(ctx context.Context, r *chi.Mux) *chi.Mux { + // Always allow API routes to work + if svr.headless { + logger.Info(ctx, "Headless mode enabled: UI is disabled, but API remains active") + + // Only register API routes, skip Web UI routes + return r + } + + // Serve assets (optional, remove if not needed) r.Get("/assets/*", svr.handleGetAssets()) + + // Serve UI pages (disable when headless) r.Get("/*", svr.handleRequest(ctx)) return r diff --git a/internal/frontend/server/server.go b/internal/frontend/server/server.go index 665adc82b..c54cb37f0 100644 --- a/internal/frontend/server/server.go +++ b/internal/frontend/server/server.go @@ -13,7 +13,7 @@ import ( "github.com/dagu-org/dagu/internal/frontend/gen/restapi" "github.com/dagu-org/dagu/internal/logger" "github.com/go-openapi/loads" - flags "github.com/jessevdk/go-flags" + "github.com/jessevdk/go-flags" "github.com/dagu-org/dagu/internal/frontend/gen/restapi/operations" pkgmiddleware "github.com/dagu-org/dagu/internal/frontend/middleware" @@ -31,6 +31,7 @@ type Server struct { server *restapi.Server handlers []Handler assets fs.FS + headless bool } type NewServerArgs struct { @@ -42,7 +43,7 @@ type NewServerArgs struct { Handlers []Handler AssetsFS fs.FS - // Configuration for the frontend + Headless bool NavbarColor string NavbarTitle string BasePath string @@ -74,6 +75,7 @@ func New(params NewServerArgs) *Server { tls: params.TLS, handlers: params.Handlers, assets: params.AssetsFS, + headless: params.Headless, // Assign headless mode flag funcsConfig: funcsConfig{ NavbarColor: params.NavbarColor, NavbarTitle: params.NavbarTitle, @@ -98,11 +100,14 @@ func (svr *Server) Shutdown(ctx context.Context) { func (svr *Server) Serve(ctx context.Context) (err error) { loggerInstance := logger.FromContext(ctx) + + // Setup middleware & routes middlewareOptions := &pkgmiddleware.Options{ - Handler: svr.defaultRoutes(ctx, chi.NewRouter()), + Handler: svr.defaultRoutes(ctx, chi.NewRouter()), // API remains active BasePath: svr.funcsConfig.BasePath, Logger: loggerInstance, } + if svr.authToken != nil { middlewareOptions.AuthToken = &pkgmiddleware.AuthToken{ Token: svr.authToken.Token, @@ -116,6 +121,7 @@ func (svr *Server) Serve(ctx context.Context) (err error) { } pkgmiddleware.Setup(middlewareOptions) + // Load API spec (Always required) swaggerSpec, err := loads.Analyzed(restapi.SwaggerJSON, "") if err != nil { logger.Error(ctx, "Failed to load API spec", "err", err) @@ -124,9 +130,10 @@ func (svr *Server) Serve(ctx context.Context) (err error) { api := operations.NewDaguAPI(swaggerSpec) api.Logger = loggerInstance.Infof for _, h := range svr.handlers { - h.Configure(api) + h.Configure(api) // Always configure API handlers } + // Start API server svr.server = restapi.NewServer(api) defer svr.Shutdown(ctx) @@ -134,25 +141,17 @@ func (svr *Server) Serve(ctx context.Context) (err error) { svr.server.Port = svr.port svr.server.ConfigureAPI() - // Server run context + // Listen for system signals (CTRL+C, termination) serverCtx, serverStopCtx := context.WithCancel(ctx) - - // Listen for syscall signals for process to interrupt/quit sig := make(chan os.Signal, 1) - signal.Notify( - sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, - ) + signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) go func() { <-sig - - // Trigger graceful shutdown - err := svr.server.Shutdown() - if err != nil { - logger.Error(ctx, "Server shutdown", "err", err) - } + _ = svr.server.Shutdown() serverStopCtx() }() + // Run with or without TLS if svr.tls != nil { svr.server.TLSCertificate = flags.Filename(svr.tls.CertFile) svr.server.TLSCertificateKey = flags.Filename(svr.tls.KeyFile) @@ -167,10 +166,7 @@ func (svr *Server) Serve(ctx context.Context) (err error) { logger.Error(ctx, "Server error", "err", err) } - // Wait for server context to be stopped <-serverCtx.Done() - logger.Info(ctx, "Server stopped") - return nil } diff --git a/internal/frontend/server/templates.go b/internal/frontend/server/templates.go index b57366cfc..43cbd9485 100644 --- a/internal/frontend/server/templates.go +++ b/internal/frontend/server/templates.go @@ -20,6 +20,13 @@ var ( ) func (srv *Server) useTemplate(ctx context.Context, layout string, name string) func(http.ResponseWriter, any) { + // Skip template rendering if headless + if srv.headless { + return func(w http.ResponseWriter, _ any) { + http.Error(w, "Web UI is disabled in headless mode", http.StatusForbidden) + } + } + files := append(baseTemplates(), filepath.Join(templatePath, layout)) tmpl, err := template.New(name).Funcs( defaultFunctions(srv.funcsConfig)).ParseFS(srv.assets, files...,