diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b5afdb1..8302d76 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -51,6 +51,7 @@ type Server struct { jobDefsDir string modules *modules.Registry cfg *config.Config + settings *db.SettingsRepository } // NewServer creates a new API server. @@ -77,6 +78,7 @@ func NewServer( itemFiles := db.NewItemFileRepository(database) dag := db.NewDAGRepository(database) jobs := db.NewJobRepository(database) + settings := db.NewSettingsRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -103,6 +105,7 @@ func NewServer( jobDefsDir: jobDefsDir, modules: registry, cfg: cfg, + settings: settings, } } diff --git a/internal/api/routes.go b/internal/api/routes.go index 9829c6c..d099b0b 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -262,6 +262,15 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/generate-part-number", server.HandleGeneratePartNumber) }) + + // Admin settings (admin only) + r.Route("/admin/settings", func(r chi.Router) { + r.Use(server.RequireRole(auth.RoleAdmin)) + r.Get("/", server.HandleGetAllSettings) + r.Get("/{module}", server.HandleGetModuleSettings) + r.Put("/{module}", server.HandleUpdateModuleSettings) + r.Post("/{module}/test", server.HandleTestModuleConnectivity) + }) }) // Runner-facing API (runner token auth, not user auth) diff --git a/internal/api/settings_handlers.go b/internal/api/settings_handlers.go new file mode 100644 index 0000000..f695f41 --- /dev/null +++ b/internal/api/settings_handlers.go @@ -0,0 +1,316 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" +) + +// HandleGetAllSettings returns the full config grouped by module with secrets redacted. +func (s *Server) HandleGetAllSettings(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "core": s.buildCoreSettings(), + "schemas": s.buildSchemasSettings(), + "storage": s.buildStorageSettings(r.Context()), + "database": s.buildDatabaseSettings(r.Context()), + "auth": s.buildAuthSettings(), + "projects": map[string]any{"enabled": s.modules.IsEnabled("projects")}, + "audit": map[string]any{"enabled": s.modules.IsEnabled("audit")}, + "odoo": s.buildOdooSettings(), + "freecad": s.buildFreecadSettings(), + "jobs": s.buildJobsSettings(), + "dag": map[string]any{"enabled": s.modules.IsEnabled("dag")}, + } + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetModuleSettings returns settings for a single module. +func (s *Server) HandleGetModuleSettings(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + var settings any + switch module { + case "core": + settings = s.buildCoreSettings() + case "schemas": + settings = s.buildSchemasSettings() + case "storage": + settings = s.buildStorageSettings(r.Context()) + case "database": + settings = s.buildDatabaseSettings(r.Context()) + case "auth": + settings = s.buildAuthSettings() + case "projects": + settings = map[string]any{"enabled": s.modules.IsEnabled("projects")} + case "audit": + settings = map[string]any{"enabled": s.modules.IsEnabled("audit")} + case "odoo": + settings = s.buildOdooSettings() + case "freecad": + settings = s.buildFreecadSettings() + case "jobs": + settings = s.buildJobsSettings() + case "dag": + settings = map[string]any{"enabled": s.modules.IsEnabled("dag")} + default: + writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module) + return + } + + writeJSON(w, http.StatusOK, settings) +} + +// HandleUpdateModuleSettings handles module toggle and config overrides. +func (s *Server) HandleUpdateModuleSettings(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + // Validate module exists + if s.modules.Get(module) == nil { + writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module) + return + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + user := auth.UserFromContext(r.Context()) + username := "system" + if user != nil { + username = user.Username + } + + var updated []string + restartRequired := false + + // Handle module toggle + if enabledVal, ok := body["enabled"]; ok { + enabled, ok := enabledVal.(bool) + if !ok { + writeError(w, http.StatusBadRequest, "invalid_value", "'enabled' must be a boolean") + return + } + + if err := s.modules.SetEnabled(module, enabled); err != nil { + writeError(w, http.StatusBadRequest, "toggle_failed", err.Error()) + return + } + + if err := s.settings.SetModuleState(r.Context(), module, enabled, username); err != nil { + s.logger.Error().Err(err).Str("module", module).Msg("failed to persist module state") + writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save module state") + return + } + + updated = append(updated, module+".enabled") + } + + // Handle config overrides (future use — persisted but not hot-reloaded) + for key, value := range body { + if key == "enabled" { + continue + } + + fullKey := module + "." + key + if err := s.settings.SetOverride(r.Context(), fullKey, value, username); err != nil { + s.logger.Error().Err(err).Str("key", fullKey).Msg("failed to persist setting override") + writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save setting: "+key) + return + } + updated = append(updated, fullKey) + + // These namespaces require a restart to take effect + if strings.HasPrefix(fullKey, "database.") || + strings.HasPrefix(fullKey, "storage.") || + strings.HasPrefix(fullKey, "server.") || + strings.HasPrefix(fullKey, "schemas.") { + restartRequired = true + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "updated": updated, + "restart_required": restartRequired, + }) + + // Publish SSE event + s.broker.Publish("settings.changed", mustMarshal(map[string]any{ + "module": module, + "changed_keys": updated, + "updated_by": username, + })) +} + +// HandleTestModuleConnectivity tests external connectivity for a module. +func (s *Server) HandleTestModuleConnectivity(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + start := time.Now() + var success bool + var message string + + switch module { + case "database": + if err := s.db.Pool().Ping(r.Context()); err != nil { + success = false + message = "Database ping failed: " + err.Error() + } else { + success = true + message = "Database connection OK" + } + case "storage": + if s.storage == nil { + success = false + message = "Storage not configured" + } else if err := s.storage.Ping(r.Context()); err != nil { + success = false + message = "Storage ping failed: " + err.Error() + } else { + success = true + message = "Storage connection OK" + } + case "auth", "odoo": + success = false + message = "Connectivity test not implemented for " + module + default: + writeError(w, http.StatusBadRequest, "not_testable", "No connectivity test available for module: "+module) + return + } + + latency := time.Since(start).Milliseconds() + + writeJSON(w, http.StatusOK, map[string]any{ + "success": success, + "message": message, + "latency_ms": latency, + }) +} + +// --- build helpers (read config, redact secrets) --- + +func redact(s string) string { + if s == "" { + return "" + } + return "****" +} + +func (s *Server) buildCoreSettings() map[string]any { + return map[string]any{ + "enabled": true, + "host": s.cfg.Server.Host, + "port": s.cfg.Server.Port, + "base_url": s.cfg.Server.BaseURL, + "readonly": s.cfg.Server.ReadOnly, + } +} + +func (s *Server) buildSchemasSettings() map[string]any { + return map[string]any{ + "enabled": true, + "directory": s.cfg.Schemas.Directory, + "default": s.cfg.Schemas.Default, + "count": len(s.schemas), + } +} + +func (s *Server) buildStorageSettings(ctx context.Context) map[string]any { + result := map[string]any{ + "enabled": true, + "endpoint": s.cfg.Storage.Endpoint, + "bucket": s.cfg.Storage.Bucket, + "use_ssl": s.cfg.Storage.UseSSL, + "region": s.cfg.Storage.Region, + } + if s.storage != nil { + if err := s.storage.Ping(ctx); err != nil { + result["status"] = "unavailable" + } else { + result["status"] = "ok" + } + } else { + result["status"] = "not_configured" + } + return result +} + +func (s *Server) buildDatabaseSettings(ctx context.Context) map[string]any { + result := map[string]any{ + "enabled": true, + "host": s.cfg.Database.Host, + "port": s.cfg.Database.Port, + "name": s.cfg.Database.Name, + "user": s.cfg.Database.User, + "password": redact(s.cfg.Database.Password), + "sslmode": s.cfg.Database.SSLMode, + "max_connections": s.cfg.Database.MaxConnections, + } + if err := s.db.Pool().Ping(ctx); err != nil { + result["status"] = "unavailable" + } else { + result["status"] = "ok" + } + return result +} + +func (s *Server) buildAuthSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("auth"), + "session_secret": redact(s.cfg.Auth.SessionSecret), + "local": map[string]any{ + "enabled": s.cfg.Auth.Local.Enabled, + "default_admin_username": s.cfg.Auth.Local.DefaultAdminUsername, + "default_admin_password": redact(s.cfg.Auth.Local.DefaultAdminPassword), + }, + "ldap": map[string]any{ + "enabled": s.cfg.Auth.LDAP.Enabled, + "url": s.cfg.Auth.LDAP.URL, + "base_dn": s.cfg.Auth.LDAP.BaseDN, + "bind_dn": s.cfg.Auth.LDAP.BindDN, + "bind_password": redact(s.cfg.Auth.LDAP.BindPassword), + }, + "oidc": map[string]any{ + "enabled": s.cfg.Auth.OIDC.Enabled, + "issuer_url": s.cfg.Auth.OIDC.IssuerURL, + "client_id": s.cfg.Auth.OIDC.ClientID, + "client_secret": redact(s.cfg.Auth.OIDC.ClientSecret), + "redirect_url": s.cfg.Auth.OIDC.RedirectURL, + }, + } +} + +func (s *Server) buildOdooSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("odoo"), + "url": s.cfg.Odoo.URL, + "database": s.cfg.Odoo.Database, + "username": s.cfg.Odoo.Username, + "api_key": redact(s.cfg.Odoo.APIKey), + } +} + +func (s *Server) buildFreecadSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("freecad"), + "uri_scheme": s.cfg.FreeCAD.URIScheme, + "executable": s.cfg.FreeCAD.Executable, + } +} + +func (s *Server) buildJobsSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("jobs"), + "directory": s.cfg.Jobs.Directory, + "runner_timeout": s.cfg.Jobs.RunnerTimeout, + "job_timeout_check": s.cfg.Jobs.JobTimeoutCheck, + "default_priority": s.cfg.Jobs.DefaultPriority, + "definitions_count": len(s.jobDefs), + } +} diff --git a/internal/api/settings_handlers_test.go b/internal/api/settings_handlers_test.go new file mode 100644 index 0000000..716afca --- /dev/null +++ b/internal/api/settings_handlers_test.go @@ -0,0 +1,285 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" + "github.com/kindredsystems/silo/internal/config" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/modules" + "github.com/kindredsystems/silo/internal/schema" + "github.com/kindredsystems/silo/internal/testutil" + "github.com/rs/zerolog" +) + +func newSettingsTestServer(t *testing.T) *Server { + t.Helper() + pool := testutil.MustConnectTestPool(t) + database := db.NewFromPool(pool) + broker := NewBroker(zerolog.Nop()) + state := NewServerState(zerolog.Nop(), nil, broker) + cfg := &config.Config{ + Server: config.ServerConfig{Host: "0.0.0.0", Port: 8080}, + Database: config.DatabaseConfig{ + Host: "localhost", Port: 5432, Name: "silo_test", + User: "silo", Password: "secret", SSLMode: "disable", + MaxConnections: 10, + }, + Storage: config.StorageConfig{ + Endpoint: "minio:9000", Bucket: "silo", Region: "us-east-1", + AccessKey: "minioadmin", SecretKey: "miniosecret", + }, + Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"}, + Auth: config.AuthConfig{ + SessionSecret: "supersecret", + Local: config.LocalAuth{Enabled: true, DefaultAdminUsername: "admin", DefaultAdminPassword: "changeme"}, + LDAP: config.LDAPAuth{Enabled: false, BindPassword: "ldapsecret"}, + OIDC: config.OIDCAuth{Enabled: false, ClientSecret: "oidcsecret"}, + }, + FreeCAD: config.FreeCADConfig{URIScheme: "silo"}, + Odoo: config.OdooConfig{URL: "https://odoo.example.com", APIKey: "odoo-api-key"}, + Jobs: config.JobsConfig{Directory: "/etc/silo/jobdefs", RunnerTimeout: 90, JobTimeoutCheck: 30, DefaultPriority: 100}, + } + return NewServer( + zerolog.Nop(), + database, + map[string]*schema.Schema{"test": {Name: "test"}}, + cfg.Schemas.Directory, + nil, // storage + nil, // authService + nil, // sessionManager + nil, // oidcBackend + nil, // authConfig + broker, + state, + nil, // jobDefs + "", // jobDefsDir + modules.NewRegistry(), // modules + cfg, + ) +} + +func newSettingsRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Route("/api/admin/settings", func(r chi.Router) { + r.Get("/", s.HandleGetAllSettings) + r.Get("/{module}", s.HandleGetModuleSettings) + r.Put("/{module}", s.HandleUpdateModuleSettings) + r.Post("/{module}/test", s.HandleTestModuleConnectivity) + }) + return r +} + +func adminSettingsRequest(r *http.Request) *http.Request { + u := &auth.User{ + ID: "admin-id", + Username: "testadmin", + Role: auth.RoleAdmin, + } + return r.WithContext(auth.ContextWithUser(r.Context(), u)) +} + +func viewerSettingsRequest(r *http.Request) *http.Request { + u := &auth.User{ + ID: "viewer-id", + Username: "testviewer", + Role: auth.RoleViewer, + } + return r.WithContext(auth.ContextWithUser(r.Context(), u)) +} + +func TestGetAllSettings(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + // Verify all module keys present + expectedModules := []string{"core", "schemas", "storage", "database", "auth", "projects", "audit", "odoo", "freecad", "jobs", "dag"} + for _, mod := range expectedModules { + if _, ok := resp[mod]; !ok { + t.Errorf("missing module key: %s", mod) + } + } + + // Verify secrets are redacted + dbSettings, _ := resp["database"].(map[string]any) + if dbSettings["password"] != "****" { + t.Errorf("database password not redacted: got %v", dbSettings["password"]) + } + + authSettings, _ := resp["auth"].(map[string]any) + if authSettings["session_secret"] != "****" { + t.Errorf("session_secret not redacted: got %v", authSettings["session_secret"]) + } + + ldap, _ := authSettings["ldap"].(map[string]any) + if ldap["bind_password"] != "****" { + t.Errorf("ldap bind_password not redacted: got %v", ldap["bind_password"]) + } + + oidc, _ := authSettings["oidc"].(map[string]any) + if oidc["client_secret"] != "****" { + t.Errorf("oidc client_secret not redacted: got %v", oidc["client_secret"]) + } + + odoo, _ := resp["odoo"].(map[string]any) + if odoo["api_key"] != "****" { + t.Errorf("odoo api_key not redacted: got %v", odoo["api_key"]) + } +} + +func TestGetModuleSettings(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings/jobs", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + if resp["directory"] != "/etc/silo/jobdefs" { + t.Errorf("jobs directory: got %v, want /etc/silo/jobdefs", resp["directory"]) + } + if resp["runner_timeout"] != float64(90) { + t.Errorf("runner_timeout: got %v, want 90", resp["runner_timeout"]) + } +} + +func TestGetModuleSettings_Unknown(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings/nonexistent", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestToggleModule(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + // Projects is enabled by default; disable it + body := `{"enabled": false}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/projects", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + updated, _ := resp["updated"].([]any) + if len(updated) != 1 || updated[0] != "projects.enabled" { + t.Errorf("updated: got %v, want [projects.enabled]", updated) + } + + // Verify registry state + if s.modules.IsEnabled("projects") { + t.Error("projects should be disabled after toggle") + } +} + +func TestToggleModule_DependencyError(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + // DAG depends on Jobs. Jobs is disabled by default. + // Enabling DAG without Jobs should fail. + body := `{"enabled": true}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/dag", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} + +func TestToggleRequiredModule(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + body := `{"enabled": false}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/core", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} + +func TestTestConnectivity_Database(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("POST", "/api/admin/settings/database/test", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + if resp["success"] != true { + t.Errorf("expected success=true, got %v; message: %v", resp["success"], resp["message"]) + } + if resp["latency_ms"] == nil { + t.Error("expected latency_ms in response") + } +} + +func TestTestConnectivity_NotTestable(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("POST", "/api/admin/settings/core/test", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} diff --git a/internal/db/settings.go b/internal/db/settings.go new file mode 100644 index 0000000..b297774 --- /dev/null +++ b/internal/db/settings.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" +) + +// SettingsRepository provides access to module_state and settings_overrides tables. +type SettingsRepository struct { + db *DB +} + +// NewSettingsRepository creates a new SettingsRepository. +func NewSettingsRepository(db *DB) *SettingsRepository { + return &SettingsRepository{db: db} +} + +// GetModuleStates returns all module enabled/disabled states from the database. +func (r *SettingsRepository) GetModuleStates(ctx context.Context) (map[string]bool, error) { + rows, err := r.db.pool.Query(ctx, + `SELECT module_id, enabled FROM module_state`) + if err != nil { + return nil, fmt.Errorf("querying module states: %w", err) + } + defer rows.Close() + + states := make(map[string]bool) + for rows.Next() { + var id string + var enabled bool + if err := rows.Scan(&id, &enabled); err != nil { + return nil, fmt.Errorf("scanning module state: %w", err) + } + states[id] = enabled + } + return states, rows.Err() +} + +// SetModuleState persists a module's enabled state. Uses upsert semantics. +func (r *SettingsRepository) SetModuleState(ctx context.Context, moduleID string, enabled bool, updatedBy string) error { + _, err := r.db.pool.Exec(ctx, + `INSERT INTO module_state (module_id, enabled, updated_by, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (module_id) DO UPDATE + SET enabled = EXCLUDED.enabled, + updated_by = EXCLUDED.updated_by, + updated_at = now()`, + moduleID, enabled, updatedBy) + if err != nil { + return fmt.Errorf("setting module state: %w", err) + } + return nil +} + +// GetOverrides returns all settings overrides from the database. +func (r *SettingsRepository) GetOverrides(ctx context.Context) (map[string]json.RawMessage, error) { + rows, err := r.db.pool.Query(ctx, + `SELECT key, value FROM settings_overrides`) + if err != nil { + return nil, fmt.Errorf("querying settings overrides: %w", err) + } + defer rows.Close() + + overrides := make(map[string]json.RawMessage) + for rows.Next() { + var key string + var value json.RawMessage + if err := rows.Scan(&key, &value); err != nil { + return nil, fmt.Errorf("scanning settings override: %w", err) + } + overrides[key] = value + } + return overrides, rows.Err() +} + +// SetOverride persists a settings override. Uses upsert semantics. +func (r *SettingsRepository) SetOverride(ctx context.Context, key string, value any, updatedBy string) error { + jsonVal, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshaling override value: %w", err) + } + _, err = r.db.pool.Exec(ctx, + `INSERT INTO settings_overrides (key, value, updated_by, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + updated_by = EXCLUDED.updated_by, + updated_at = now()`, + key, jsonVal, updatedBy) + if err != nil { + return fmt.Errorf("setting override: %w", err) + } + return nil +} + +// DeleteOverride removes a settings override. +func (r *SettingsRepository) DeleteOverride(ctx context.Context, key string) error { + _, err := r.db.pool.Exec(ctx, + `DELETE FROM settings_overrides WHERE key = $1`, key) + if err != nil { + return fmt.Errorf("deleting override: %w", err) + } + return nil +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b5e33b2..b28e073 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -352,6 +352,35 @@ export interface UpdateSchemaValueRequest { description: string; } +// Admin settings — module discovery +export interface ModuleInfo { + enabled: boolean; + required: boolean; + name: string; + version?: string; + depends_on?: string[]; + config?: Record; +} + +export interface ModulesResponse { + modules: Record; + server: { version: string; read_only: boolean }; +} + +// Admin settings — config management +export type AdminSettingsResponse = Record>; + +export interface UpdateSettingsResponse { + updated: string[]; + restart_required: boolean; +} + +export interface TestConnectivityResponse { + success: boolean; + message: string; + latency_ms: number; +} + // Revision comparison export interface RevisionComparison { from: number; diff --git a/web/src/components/settings/AdminModules.tsx b/web/src/components/settings/AdminModules.tsx new file mode 100644 index 0000000..9d84d30 --- /dev/null +++ b/web/src/components/settings/AdminModules.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from "react"; +import { get } from "../../api/client"; +import type { + ModuleInfo, + ModulesResponse, + AdminSettingsResponse, + UpdateSettingsResponse, +} from "../../api/types"; +import { ModuleCard } from "./ModuleCard"; + +const infraModules = ["core", "schemas", "database", "storage"]; +const featureModules = [ + "auth", + "projects", + "audit", + "freecad", + "odoo", + "jobs", + "dag", +]; + +export function AdminModules() { + const [modules, setModules] = useState | null>( + null, + ); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [restartRequired, setRestartRequired] = useState(false); + + useEffect(() => { + Promise.all([ + get("/api/modules"), + get("/api/admin/settings"), + ]) + .then(([modsResp, settingsResp]) => { + setModules(modsResp.modules); + setSettings(settingsResp); + }) + .catch((e) => + setError(e instanceof Error ? e.message : "Failed to load settings"), + ) + .finally(() => setLoading(false)); + }, []); + + const handleSaved = (moduleId: string, result: UpdateSettingsResponse) => { + if (result.restart_required) setRestartRequired(true); + // Refresh the single module's settings + get>(`/api/admin/settings/${moduleId}`) + .then((updated) => + setSettings((prev) => (prev ? { ...prev, [moduleId]: updated } : prev)), + ) + .catch(() => {}); + }; + + const handleToggled = (moduleId: string, enabled: boolean) => { + setModules((prev) => { + if (!prev || !prev[moduleId]) return prev; + const updated: Record = { + ...prev, + [moduleId]: { ...prev[moduleId], enabled }, + }; + return updated; + }); + }; + + if (loading) { + return ( +
+

Module Configuration

+

Loading modules...

+
+ ); + } + + if (error) { + return ( +
+

Module Configuration

+

+ {error} +

+
+ ); + } + + if (!modules || !settings) return null; + + const renderGroup = (title: string, ids: string[]) => { + const available = ids.filter((id) => modules[id]); + if (available.length === 0) return null; + return ( +
+
{title}
+ {available.map((id) => { + const meta = modules[id]; + if (!meta) return null; + return ( + + ); + })} +
+ ); + }; + + return ( +
+

Module Configuration

+ + {restartRequired && ( +
+ Restart required + Some changes require a server restart to take effect. + +
+ )} + + {renderGroup("Infrastructure", infraModules)} + {renderGroup("Features", featureModules)} +
+ ); +} + +// --- Styles --- + +const sectionStyle: React.CSSProperties = { + marginTop: "0.5rem", +}; + +const sectionTitleStyle: React.CSSProperties = { + marginBottom: "1rem", + fontSize: "var(--font-title)", +}; + +const groupTitleStyle: React.CSSProperties = { + fontSize: "0.7rem", + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.08em", + color: "var(--ctp-overlay1)", + marginBottom: "0.5rem", +}; + +const restartBannerStyle: React.CSSProperties = { + display: "flex", + gap: "0.75rem", + alignItems: "center", + padding: "0.75rem 1rem", + marginBottom: "1rem", + borderRadius: "0.75rem", + background: "rgba(249, 226, 175, 0.1)", + border: "1px solid rgba(249, 226, 175, 0.3)", + color: "var(--ctp-yellow)", + fontSize: "var(--font-body)", +}; + +const dismissBtnStyle: React.CSSProperties = { + marginLeft: "auto", + padding: "0.25rem 0.5rem", + borderRadius: "0.25rem", + border: "none", + background: "rgba(249, 226, 175, 0.15)", + color: "var(--ctp-yellow)", + cursor: "pointer", + fontSize: "0.7rem", + fontWeight: 500, +}; diff --git a/web/src/components/settings/ModuleCard.tsx b/web/src/components/settings/ModuleCard.tsx new file mode 100644 index 0000000..1dcf80c --- /dev/null +++ b/web/src/components/settings/ModuleCard.tsx @@ -0,0 +1,655 @@ +import { useState } from "react"; +import { put, post } from "../../api/client"; +import type { + ModuleInfo, + UpdateSettingsResponse, + TestConnectivityResponse, +} from "../../api/types"; + +interface ModuleCardProps { + moduleId: string; + meta: ModuleInfo; + settings: Record; + allModules: Record; + onSaved: (moduleId: string, result: UpdateSettingsResponse) => void; + onToggled: (moduleId: string, enabled: boolean) => void; +} + +const testableModules = new Set(["database", "storage"]); + +export function ModuleCard({ + moduleId, + meta, + settings, + allModules, + onSaved, + onToggled, +}: ModuleCardProps) { + const [expanded, setExpanded] = useState(false); + const [enabled, setEnabled] = useState(meta.enabled); + const [toggling, setToggling] = useState(false); + const [toggleError, setToggleError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState( + null, + ); + const [edits, setEdits] = useState>({}); + + const hasEdits = Object.keys(edits).length > 0; + const isTestable = testableModules.has(moduleId); + const hasFields = !["projects", "audit", "dag"].includes(moduleId); + const deps = meta.depends_on ?? []; + const status = settings.status as string | undefined; + + const handleToggle = async () => { + const next = !enabled; + setToggling(true); + setToggleError(null); + try { + const result = await put( + `/api/admin/settings/${moduleId}`, + { enabled: next }, + ); + setEnabled(next); + onToggled(moduleId, next); + onSaved(moduleId, result); + } catch (e) { + setToggleError(e instanceof Error ? e.message : "Toggle failed"); + } finally { + setToggling(false); + } + }; + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + setSaveSuccess(false); + try { + const result = await put( + `/api/admin/settings/${moduleId}`, + edits, + ); + setEdits({}); + setSaveSuccess(true); + onSaved(moduleId, result); + setTimeout(() => setSaveSuccess(false), 3000); + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + const result = await post( + `/api/admin/settings/${moduleId}/test`, + ); + setTestResult(result); + } catch (e) { + setTestResult({ + success: false, + message: e instanceof Error ? e.message : "Test failed", + latency_ms: 0, + }); + } finally { + setTesting(false); + } + }; + + const setField = (key: string, value: unknown) => { + setEdits((prev) => ({ ...prev, [key]: value })); + setSaveSuccess(false); + }; + + const getFieldValue = (key: string): unknown => { + if (key in edits) return edits[key]; + return settings[key]; + }; + + const statusBadge = () => { + if (!enabled && !meta.required) + return Disabled; + if (status === "unavailable") + return Error; + return Active; + }; + + return ( +
+ {/* Header */} +
hasFields && setExpanded(!expanded)} + > +
+ {!meta.required && ( + + )} + + {meta.name} + + {statusBadge()} +
+ {hasFields && ( + + ▼ + + )} +
+ + {/* Toggle error */} + {toggleError && ( +
+ {toggleError} +
+ )} + + {/* Dependencies note */} + {deps.length > 0 && expanded && ( +
+ Requires:{" "} + {deps.map((d) => allModules[d]?.name ?? d).join(", ")} +
+ )} + + {/* Body */} + {expanded && hasFields && ( +
+ {renderModuleFields(moduleId, settings, getFieldValue, setField)} + + {/* Footer */} +
+
+ {hasEdits && ( + + )} + {isTestable && ( + + )} +
+
+ {saveSuccess && ( + + Saved + + )} + {saveError && ( + + {saveError} + + )} +
+
+ + {/* Test result */} + {testResult && ( +
+ + {testResult.success ? "OK" : "Failed"} + + + {testResult.message} + + {testResult.latency_ms > 0 && ( + + {testResult.latency_ms}ms + + )} +
+ )} +
+ )} +
+ ); +} + +// --- Field renderers per module --- + +function renderModuleFields( + moduleId: string, + settings: Record, + getValue: (key: string) => unknown, + setValue: (key: string, value: unknown) => void, +) { + switch (moduleId) { + case "core": + return ( + + + + + + + ); + case "schemas": + return ( + + + + + + ); + case "database": + return ( + + + + + + + + + ); + case "storage": + return ( + + + + + + + ); + case "auth": + return renderAuthFields(settings); + case "freecad": + return ( + + setValue("uri_scheme", v)} + /> + setValue("executable", v)} + /> + + ); + case "odoo": + return ( + + setValue("url", v)} + /> + setValue("database", v)} + /> + setValue("username", v)} + /> + + ); + case "jobs": + return ( + + setValue("directory", v)} + /> + setValue("runner_timeout", Number(v))} + type="number" + /> + setValue("job_timeout_check", Number(v))} + type="number" + /> + setValue("default_priority", Number(v))} + type="number" + /> + + ); + default: + return null; + } +} + +function renderAuthFields(settings: Record) { + const local = (settings.local ?? {}) as Record; + const ldap = (settings.ldap ?? {}) as Record; + const oidc = (settings.oidc ?? {}) as Record; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +// --- Shared field components --- + +function FieldGrid({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function SubSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function ReadOnlyField({ + label, + value, +}: { + label: string; + value: unknown; +}) { + const display = + value === undefined || value === null || value === "" + ? "—" + : String(value); + return ( +
+
{label}
+
{display}
+
+ ); +} + +function EditableField({ + label, + value, + onChange, + type = "text", +}: { + label: string; + value: unknown; + onChange: (v: string) => void; + type?: string; +}) { + const strVal = value === undefined || value === null ? "" : String(value); + const isRedacted = strVal === "****"; + return ( +
+
{label}
+ onChange(e.target.value)} + placeholder={isRedacted ? "••••••••" : undefined} + className="silo-input" + style={fieldInputStyle} + /> +
+ ); +} + +// --- Styles --- + +const cardStyle: React.CSSProperties = { + backgroundColor: "var(--ctp-surface0)", + borderRadius: "0.75rem", + marginBottom: "0.75rem", + overflow: "hidden", +}; + +const headerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "1rem 1.5rem", + cursor: "pointer", + userSelect: "none", +}; + +const bodyStyle: React.CSSProperties = { + padding: "0 1.5rem 1.25rem", +}; + +const footerStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginTop: "1rem", + paddingTop: "0.75rem", + borderTop: "1px solid var(--ctp-surface1)", +}; + +const toggleBtnStyle: React.CSSProperties = { + position: "relative", + width: 34, + height: 20, + borderRadius: 10, + border: "none", + cursor: "pointer", + padding: 0, + flexShrink: 0, + transition: "background-color 0.15s ease", +}; + +const toggleKnobStyle: React.CSSProperties = { + position: "absolute", + top: 3, + left: 3, + width: 14, + height: 14, + borderRadius: "50%", + backgroundColor: "var(--ctp-crust)", + transition: "transform 0.15s ease", +}; + +const badgeBase: React.CSSProperties = { + display: "inline-block", + padding: "0.15rem 0.5rem", + borderRadius: "1rem", + fontSize: "0.65rem", + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.05em", +}; + +const badgeStyles = { + active: { + ...badgeBase, + background: "rgba(166, 227, 161, 0.2)", + color: "var(--ctp-green)", + } as React.CSSProperties, + disabled: { + ...badgeBase, + background: "rgba(147, 153, 178, 0.15)", + color: "var(--ctp-overlay1)", + } as React.CSSProperties, + error: { + ...badgeBase, + background: "rgba(243, 139, 168, 0.2)", + color: "var(--ctp-red)", + } as React.CSSProperties, +}; + +const errorStyle: React.CSSProperties = { + color: "var(--ctp-red)", + fontSize: "var(--font-body)", +}; + +const depNoteStyle: React.CSSProperties = { + padding: "0 1.5rem", + color: "var(--ctp-overlay1)", + fontSize: "var(--font-body)", + fontStyle: "italic", +}; + +const fieldGridStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: "0.75rem 1.5rem", +}; + +const subSectionTitleStyle: React.CSSProperties = { + fontSize: "0.7rem", + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.05em", + color: "var(--ctp-overlay1)", + marginBottom: "0.5rem", + paddingBottom: "0.25rem", + borderBottom: "1px solid var(--ctp-surface1)", +}; + +const fieldLabelStyle: React.CSSProperties = { + color: "var(--ctp-subtext0)", + fontSize: "var(--font-body)", + fontWeight: 500, + marginBottom: "0.2rem", +}; + +const fieldValueStyle: React.CSSProperties = { + fontSize: "var(--font-body)", + color: "var(--ctp-text)", + fontFamily: "'JetBrains Mono', monospace", +}; + +const fieldInputStyle: React.CSSProperties = { + width: "100%", + padding: "0.4rem 0.6rem", + backgroundColor: "var(--ctp-base)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.375rem", + color: "var(--ctp-text)", + fontSize: "var(--font-body)", + boxSizing: "border-box", +}; + +const btnPrimaryStyle: React.CSSProperties = { + padding: "0.4rem 0.75rem", + borderRadius: "0.25rem", + border: "none", + backgroundColor: "var(--ctp-mauve)", + color: "var(--ctp-crust)", + fontWeight: 500, + fontSize: "0.75rem", + cursor: "pointer", +}; + +const btnSecondaryStyle: React.CSSProperties = { + padding: "0.4rem 0.75rem", + borderRadius: "0.25rem", + border: "1px solid var(--ctp-surface2)", + backgroundColor: "transparent", + color: "var(--ctp-subtext1)", + fontWeight: 500, + fontSize: "0.75rem", + cursor: "pointer", +}; + +const testResultStyle: React.CSSProperties = { + display: "flex", + gap: "0.75rem", + alignItems: "center", + marginTop: "0.75rem", + padding: "0.5rem 0.75rem", + borderRadius: "0.5rem", + border: "1px solid", +}; diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 0ca6fbf..4a32a32 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react"; import { get, post, del } from "../api/client"; import { useAuth } from "../hooks/useAuth"; import type { ApiToken, ApiTokenCreated } from "../api/types"; +import { AdminModules } from "../components/settings/AdminModules"; export function SettingsPage() { const { user } = useAuth(); @@ -311,6 +312,9 @@ export function SettingsPage() { )} + + {/* Admin: Module Configuration */} + {user?.role === "admin" && } ); }