diff --git a/cmd/server/main.go b/cmd/server/main.go index aaf59157..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" @@ -115,6 +116,7 @@ func buildEngine(cfg *config.WorkflowConfig, logger *slog.Logger) (*workflow.Std pluginintegration.New(), pluginai.New(), pluginplatform.New(), + pluginadmin.New().WithUIDir(*adminUIDir), } for _, p := range plugins { if err := engine.LoadPlugin(p); err != nil { @@ -305,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) @@ -345,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 new file mode 100644 index 00000000..91278ed7 --- /dev/null +++ b/plugins/admin/plugin.go @@ -0,0 +1,238 @@ +// Package admin provides an EnginePlugin that serves the admin dashboard UI +// and loads admin config routes. It encapsulates admin concerns — static file +// serving, config merging, and service delegate wiring — as a self-contained +// plugin rather than hard-wired logic in cmd/server/main.go. +package admin + +import ( + "fmt" + "log/slog" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/admin" + "github.com/GoCodeAlone/workflow/capability" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/schema" +) + +// Plugin provides admin-specific module types and wiring hooks: +// - admin.dashboard — serves the admin UI static files via a static.fileserver +// - admin.config_loader — loads admin/config.yaml and merges routes into the engine +type Plugin struct { + plugin.BaseEnginePlugin + + // UIDir overrides the static file root for the admin dashboard. + // Empty string means use the default from admin/config.yaml. + UIDir string + + // Logger for wiring hook diagnostics. + Logger *slog.Logger +} + +// New creates a new admin plugin. +func New() *Plugin { + return &Plugin{ + BaseEnginePlugin: plugin.BaseEnginePlugin{ + BaseNativePlugin: plugin.BaseNativePlugin{ + PluginName: "admin", + PluginVersion: "1.0.0", + PluginDescription: "Admin dashboard UI and config-driven admin routes", + }, + Manifest: plugin.PluginManifest{ + Name: "admin", + Version: "1.0.0", + Author: "GoCodeAlone", + Description: "Admin dashboard UI and config-driven admin routes", + Tier: plugin.TierCore, + ModuleTypes: []string{ + "admin.dashboard", + "admin.config_loader", + }, + WiringHooks: []string{ + "admin-config-merge", + }, + Capabilities: []plugin.CapabilityDecl{ + {Name: "admin-ui", Role: "provider", Priority: 10}, + {Name: "admin-config", Role: "provider", Priority: 10}, + }, + }, + }, + } +} + +// WithUIDir sets the static file root for the admin dashboard. +func (p *Plugin) WithUIDir(dir string) *Plugin { + p.UIDir = dir + return p +} + +// WithLogger sets the logger for wiring hook diagnostics. +func (p *Plugin) WithLogger(logger *slog.Logger) *Plugin { + p.Logger = logger + return p +} + +// Capabilities returns the capability contracts this plugin defines. +func (p *Plugin) Capabilities() []capability.Contract { + return []capability.Contract{ + { + Name: "admin-ui", + Description: "Serves the admin dashboard UI as static files with SPA fallback", + }, + { + Name: "admin-config", + Description: "Loads and merges admin config routes into the workflow engine", + }, + } +} + +// ModuleFactories returns factories for admin module types. +func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { + return map[string]plugin.ModuleFactory{ + "admin.dashboard": func(name string, cfg map[string]any) modular.Module { + root := "" + if r, ok := cfg["root"].(string); ok { + root = r + } + root = config.ResolvePathInConfig(cfg, root) + if p.UIDir != "" { + root = p.UIDir + } + prefix := "/" + if pfx, ok := cfg["prefix"].(string); ok { + prefix = pfx + } + // 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) + }, + } +} + +// ModuleSchemas returns UI schema definitions for admin module types. +func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { + return []*schema.ModuleSchema{ + { + Type: "admin.dashboard", + Label: "Admin Dashboard", + Category: "admin", + Description: "Serves the admin UI static files with SPA fallback", + Inputs: []schema.ServiceIODef{{Name: "http_request", Type: "http.Request", Description: "HTTP request for admin UI"}}, + Outputs: []schema.ServiceIODef{{Name: "http_response", Type: "http.Response", Description: "Static file or SPA fallback"}}, + ConfigFields: []schema.ConfigFieldDef{ + {Key: "root", Label: "UI Root Directory", Type: schema.FieldTypeString, Description: "Path to admin UI static assets directory", Placeholder: "ui/dist"}, + }, + }, + { + Type: "admin.config_loader", + Label: "Admin Config Loader", + Category: "admin", + Description: "Loads the embedded admin config and merges routes into the engine", + Inputs: []schema.ServiceIODef{}, + Outputs: []schema.ServiceIODef{{Name: "config", Type: "WorkflowConfig", Description: "Merged admin configuration"}}, + }, + } +} + +// WiringHooks returns post-init wiring functions that merge admin config +// into the running engine. +func (p *Plugin) WiringHooks() []plugin.WiringHook { + return []plugin.WiringHook{ + { + Name: "admin-config-merge", + Priority: 100, // run early so admin routes are available + Hook: func(_ modular.Application, cfg *config.WorkflowConfig) error { + return p.mergeAdminConfig(cfg) + }, + }, + } +} + +// mergeAdminConfig loads the embedded admin config and merges it into the +// primary config. If UIDir is set, the static fileserver root is overridden. +func (p *Plugin) mergeAdminConfig(cfg *config.WorkflowConfig) error { + logger := p.Logger + if logger == nil { + logger = slog.Default() + } + + // Skip merge if admin modules are already present + for _, m := range cfg.Modules { + if m.Name == "admin-server" { + logger.Info("Config already contains admin modules, skipping merge") + if p.UIDir != "" { + injectUIRoot(cfg, p.UIDir) + logger.Info("Admin UI root overridden", "uiDir", p.UIDir) + } + return nil + } + } + + adminCfg, err := admin.LoadConfig() + if err != nil { + return fmt.Errorf("admin plugin: load config: %w", err) + } + + if p.UIDir != "" { + injectUIRoot(adminCfg, p.UIDir) + logger.Info("Admin UI root overridden", "uiDir", p.UIDir) + } + + admin.MergeInto(cfg, adminCfg) + logger.Info("Admin UI enabled via admin plugin") + return nil +} + +// 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" || cfg.Modules[i].Type == "admin.dashboard" { + if cfg.Modules[i].Config == nil { + cfg.Modules[i].Config = make(map[string]any) + } + cfg.Modules[i].Config["root"] = uiRoot + } + } +} + +// configLoaderModule is a minimal modular.Module that represents the admin +// config loading concern. It is used as a dependency anchor — other modules +// can depend on it to ensure admin config is loaded first. +type configLoaderModule struct { + name string +} + +func newConfigLoaderModule(name string) *configLoaderModule { + return &configLoaderModule{name: name} +} + +func (m *configLoaderModule) Name() string { return m.name } +func (m *configLoaderModule) Dependencies() []string { return nil } +func (m *configLoaderModule) ProvidesServices() []modular.ServiceProvider { return nil } +func (m *configLoaderModule) RequiresServices() []modular.ServiceDependency { return nil } +func (m *configLoaderModule) RegisterConfig(_ modular.Application) error { return nil } +func (m *configLoaderModule) Init(_ modular.Application) error { return nil } +func (m *configLoaderModule) Start(_ modular.Application) error { return nil } +func (m *configLoaderModule) Stop(_ modular.Application) error { return nil } diff --git a/plugins/admin/plugin_test.go b/plugins/admin/plugin_test.go new file mode 100644 index 00000000..4fddcbb1 --- /dev/null +++ b/plugins/admin/plugin_test.go @@ -0,0 +1,274 @@ +package admin + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/plugin" +) + +func TestPluginImplementsEnginePlugin(t *testing.T) { + p := New() + var _ plugin.EnginePlugin = p +} + +func TestPluginManifest(t *testing.T) { + p := New() + m := p.EngineManifest() + + if err := m.Validate(); err != nil { + t.Fatalf("manifest validation failed: %v", err) + } + if m.Name != "admin" { + t.Errorf("expected name %q, got %q", "admin", m.Name) + } + if len(m.ModuleTypes) != 2 { + t.Errorf("expected 2 module types, got %d", len(m.ModuleTypes)) + } +} + +func TestPluginCapabilities(t *testing.T) { + p := New() + caps := p.Capabilities() + if len(caps) != 2 { + t.Fatalf("expected 2 capabilities, got %d", len(caps)) + } + names := map[string]bool{} + for _, c := range caps { + names[c.Name] = true + } + for _, expected := range []string{"admin-ui", "admin-config"} { + if !names[expected] { + t.Errorf("missing capability %q", expected) + } + } +} + +func TestModuleFactories(t *testing.T) { + p := New() + factories := p.ModuleFactories() + + expectedTypes := []string{"admin.dashboard", "admin.config_loader"} + for _, typ := range expectedTypes { + factory, ok := factories[typ] + if !ok { + t.Errorf("missing factory for %q", typ) + continue + } + mod := factory("test-"+typ, map[string]any{}) + if mod == nil { + t.Errorf("factory for %q returned nil", typ) + } + } +} + +func TestDashboardFactoryWithRoot(t *testing.T) { + p := New() + factories := p.ModuleFactories() + + mod := factories["admin.dashboard"]("dash", map[string]any{ + "root": "/tmp/admin-ui", + }) + if mod == nil { + t.Fatal("admin.dashboard factory returned nil") + } + if mod.Name() != "dash" { + t.Errorf("expected name %q, got %q", "dash", mod.Name()) + } +} + +func TestDashboardFactoryWithUIDir(t *testing.T) { + p := New().WithUIDir("/custom/ui") + factories := p.ModuleFactories() + + mod := factories["admin.dashboard"]("dash-override", map[string]any{ + "root": "/should-be-overridden", + }) + if mod == nil { + t.Fatal("admin.dashboard factory returned nil with UIDir override") + } +} + +func TestConfigLoaderFactory(t *testing.T) { + p := New() + factories := p.ModuleFactories() + + mod := factories["admin.config_loader"]("loader", map[string]any{}) + if mod == nil { + t.Fatal("admin.config_loader factory returned nil") + } + if mod.Name() != "loader" { + t.Errorf("expected name %q, got %q", "loader", mod.Name()) + } +} + +func TestModuleSchemas(t *testing.T) { + p := New() + schemas := p.ModuleSchemas() + if len(schemas) != 2 { + t.Fatalf("expected 2 module schemas, got %d", len(schemas)) + } + + types := map[string]bool{} + for _, s := range schemas { + types[s.Type] = true + } + for _, expected := range []string{"admin.dashboard", "admin.config_loader"} { + if !types[expected] { + t.Errorf("missing schema for %q", expected) + } + } +} + +func TestWiringHooks(t *testing.T) { + p := New() + hooks := p.WiringHooks() + if len(hooks) != 1 { + t.Fatalf("expected 1 wiring hook, got %d", len(hooks)) + } + if hooks[0].Name != "admin-config-merge" { + t.Errorf("expected hook name %q, got %q", "admin-config-merge", hooks[0].Name) + } +} + +func TestWiringHookMergesAdminConfig(t *testing.T) { + p := New() + hooks := p.WiringHooks() + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "my-server", Type: "http.server"}, + }, + } + + err := hooks[0].Hook(nil, cfg) + if err != nil { + t.Fatalf("wiring hook failed: %v", err) + } + + // After merge, admin modules should be present + found := false + for _, m := range cfg.Modules { + if m.Name == "admin-server" { + found = true + break + } + } + if !found { + t.Error("admin-server module not found after wiring hook merge") + } +} + +func TestWiringHookSkipsIfAlreadyPresent(t *testing.T) { + p := New() + hooks := p.WiringHooks() + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "admin-server", Type: "http.server", Config: map[string]any{"address": ":8081"}}, + }, + } + + originalLen := len(cfg.Modules) + err := hooks[0].Hook(nil, cfg) + if err != nil { + t.Fatalf("wiring hook failed: %v", err) + } + + // Should not duplicate admin modules + if len(cfg.Modules) != originalLen { + t.Errorf("expected %d modules (no duplicates), got %d", originalLen, len(cfg.Modules)) + } +} + +func TestWiringHookInjectsUIDir(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-ui", Type: "static.fileserver", Config: map[string]any{"root": "ui/dist"}}, + }, + } + + err := hooks[0].Hook(nil, cfg) + if err != nil { + t.Fatalf("wiring hook failed: %v", err) + } + + // The static.fileserver should have the overridden root + for _, m := range cfg.Modules { + if m.Type == "static.fileserver" { + if m.Config["root"] != "/custom/admin-ui" { + t.Errorf("expected root %q, got %q", "/custom/admin-ui", m.Config["root"]) + } + } + } +} + +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) + if p2 != p { + t.Error("expected chained With* calls to return the same *Plugin") + } +} + +func TestPluginNameAndVersion(t *testing.T) { + p := New() + if p.Name() != "admin" { + t.Errorf("expected name %q, got %q", "admin", p.Name()) + } + if p.Version() != "1.0.0" { + t.Errorf("expected version %q, got %q", "1.0.0", p.Version()) + } +} + +func TestConfigLoaderModuleInterface(t *testing.T) { + m := newConfigLoaderModule("test-loader") + if m.Name() != "test-loader" { + t.Errorf("expected name %q, got %q", "test-loader", m.Name()) + } + if deps := m.Dependencies(); deps != nil { + t.Errorf("expected nil dependencies, got %v", deps) + } + if err := m.RegisterConfig(nil); err != nil { + t.Errorf("RegisterConfig returned error: %v", err) + } + if err := m.Init(nil); err != nil { + t.Errorf("Init returned error: %v", err) + } + if err := m.Start(nil); err != nil { + t.Errorf("Start returned error: %v", err) + } + if err := m.Stop(nil); err != nil { + t.Errorf("Stop returned error: %v", err) + } +}