diff --git a/cmd/server/main.go b/cmd/server/main.go index b5e4a7d7..93e9aec0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -37,6 +37,7 @@ import ( _ "github.com/GoCodeAlone/workflow/plugin/docmanager" pluginexternal "github.com/GoCodeAlone/workflow/plugin/external" _ "github.com/GoCodeAlone/workflow/plugin/storebrowser" + pluginadmin "github.com/GoCodeAlone/workflow/plugins/admin" pluginai "github.com/GoCodeAlone/workflow/plugins/ai" pluginapi "github.com/GoCodeAlone/workflow/plugins/api" pluginauth "github.com/GoCodeAlone/workflow/plugins/auth" @@ -49,7 +50,6 @@ import ( pluginmodcompat "github.com/GoCodeAlone/workflow/plugins/modularcompat" pluginobs "github.com/GoCodeAlone/workflow/plugins/observability" pluginpipeline "github.com/GoCodeAlone/workflow/plugins/pipelinesteps" - pluginadmin "github.com/GoCodeAlone/workflow/plugins/admin" pluginplatform "github.com/GoCodeAlone/workflow/plugins/platform" pluginscheduler "github.com/GoCodeAlone/workflow/plugins/scheduler" pluginsecrets "github.com/GoCodeAlone/workflow/plugins/secrets" @@ -307,13 +307,7 @@ func setup(logger *slog.Logger, cfg *config.WorkflowConfig) (*serverApp, error) logger: logger, } - // Merge admin config into primary config — admin UI is always enabled. - // The admin config provides all management endpoints (auth, API, schema, - // AI, dynamic components) via the engine's own modules and routes. - if err := mergeAdminConfig(logger, cfg); err != nil { - return nil, fmt.Errorf("failed to set up admin: %w", err) - } - + // Admin config is merged by the admin plugin's wiring hook during buildEngine. engine, loader, registry, err := buildEngine(cfg, logger) if err != nil { return nil, fmt.Errorf("failed to build engine: %w", err) @@ -347,59 +341,6 @@ func setup(logger *slog.Logger, cfg *config.WorkflowConfig) (*serverApp, error) return app, nil } -// mergeAdminConfig loads the embedded admin config and merges admin -// modules/routes into the primary config. If --admin-ui-dir (or ADMIN_UI_DIR -// env var) is set the static.fileserver root is updated to that path, -// allowing the admin UI to be deployed and updated independently of the binary. -// If the config already contains admin modules (e.g., the user passed the -// admin config directly), the merge is skipped to avoid duplicates — but -// the UI root is still injected so the static fileserver works. -func mergeAdminConfig(logger *slog.Logger, cfg *config.WorkflowConfig) error { - // Resolve the UI root: flag > ADMIN_UI_DIR env > leave as configured in config.yaml - uiDir := *adminUIDir - - // Check if the config already contains admin modules - for _, m := range cfg.Modules { - if m.Name == "admin-server" { - logger.Info("Config already contains admin modules, skipping merge") - if uiDir != "" { - injectUIRoot(cfg, uiDir) - logger.Info("Admin UI root overridden", "uiDir", uiDir) - } - return nil - } - } - - adminCfg, err := admin.LoadConfig() - if err != nil { - return err - } - - if uiDir != "" { - injectUIRoot(adminCfg, uiDir) - logger.Info("Admin UI root overridden", "uiDir", uiDir) - } - - // Merge admin modules and routes into primary config - admin.MergeInto(cfg, adminCfg) - - logger.Info("Admin UI enabled") - return nil -} - -// injectUIRoot updates every static.fileserver module config in cfg to serve -// from the given root directory. -func injectUIRoot(cfg *config.WorkflowConfig, uiRoot string) { - for i := range cfg.Modules { - if cfg.Modules[i].Type == "static.fileserver" { - if cfg.Modules[i].Config == nil { - cfg.Modules[i].Config = make(map[string]any) - } - cfg.Modules[i].Config["root"] = uiRoot - } - } -} - // initManagementHandlers creates all management service handlers and stores // them on the serverApp struct. These handlers are created once and persist // across engine reloads. Only the service registrations need to be refreshed. diff --git a/plugins/admin/plugin.go b/plugins/admin/plugin.go index 84a73b2a..91278ed7 100644 --- a/plugins/admin/plugin.go +++ b/plugins/admin/plugin.go @@ -96,6 +96,7 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { if r, ok := cfg["root"].(string); ok { root = r } + root = config.ResolvePathInConfig(cfg, root) if p.UIDir != "" { root = p.UIDir } @@ -103,7 +104,25 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { if pfx, ok := cfg["prefix"].(string); ok { prefix = pfx } - return module.NewStaticFileServer(name, root, prefix) + // SPA fallback is enabled by default for the admin dashboard UI. + spaFallback := true + if sf, ok := cfg["spaFallback"].(bool); ok { + spaFallback = sf + } + var opts []module.StaticFileServerOption + if spaFallback { + opts = append(opts, module.WithSPAFallback()) + } + if cma, ok := cfg["cacheMaxAge"].(int); ok { + opts = append(opts, module.WithCacheMaxAge(cma)) + } else if cma, ok := cfg["cacheMaxAge"].(float64); ok { + opts = append(opts, module.WithCacheMaxAge(int(cma))) + } + sfs := module.NewStaticFileServer(name, root, prefix, opts...) + if routerName, ok := cfg["router"].(string); ok && routerName != "" { + sfs.SetRouterName(routerName) + } + return sfs }, "admin.config_loader": func(name string, _ map[string]any) modular.Module { return newConfigLoaderModule(name) @@ -185,11 +204,11 @@ func (p *Plugin) mergeAdminConfig(cfg *config.WorkflowConfig) error { return nil } -// injectUIRoot updates every static.fileserver module config in cfg to serve -// from the given root directory. +// injectUIRoot updates every static.fileserver and admin.dashboard module +// config in cfg to serve from the given root directory. func injectUIRoot(cfg *config.WorkflowConfig, uiRoot string) { for i := range cfg.Modules { - if cfg.Modules[i].Type == "static.fileserver" { + if cfg.Modules[i].Type == "static.fileserver" || cfg.Modules[i].Type == "admin.dashboard" { if cfg.Modules[i].Config == nil { cfg.Modules[i].Config = make(map[string]any) } diff --git a/plugins/admin/plugin_test.go b/plugins/admin/plugin_test.go index 3ecd3c41..4fddcbb1 100644 --- a/plugins/admin/plugin_test.go +++ b/plugins/admin/plugin_test.go @@ -207,6 +207,32 @@ func TestWiringHookInjectsUIDir(t *testing.T) { } } +func TestWiringHookInjectsUIDirForAdminDashboard(t *testing.T) { + p := New().WithUIDir("/custom/admin-ui") + hooks := p.WiringHooks() + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "admin-server", Type: "http.server"}, + {Name: "admin-dashboard", Type: "admin.dashboard", Config: map[string]any{"root": "ui/dist"}}, + }, + } + + err := hooks[0].Hook(nil, cfg) + if err != nil { + t.Fatalf("wiring hook failed: %v", err) + } + + // The admin.dashboard should have the overridden root + for _, m := range cfg.Modules { + if m.Type == "admin.dashboard" { + if m.Config["root"] != "/custom/admin-ui" { + t.Errorf("expected root %q, got %q", "/custom/admin-ui", m.Config["root"]) + } + } + } +} + func TestWithLoggerChaining(t *testing.T) { p := New() p2 := p.WithUIDir("/dir").WithLogger(nil)