Merge pull request 'feat(web): admin settings page — module cards, toggles, config forms' (#105) from feat-admin-settings-api into main
Reviewed-on: #105
This commit was merged in pull request #105.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
316
internal/api/settings_handlers.go
Normal file
316
internal/api/settings_handlers.go
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
285
internal/api/settings_handlers_test.go
Normal file
285
internal/api/settings_handlers_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
105
internal/db/settings.go
Normal file
105
internal/db/settings.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ModulesResponse {
|
||||
modules: Record<string, ModuleInfo>;
|
||||
server: { version: string; read_only: boolean };
|
||||
}
|
||||
|
||||
// Admin settings — config management
|
||||
export type AdminSettingsResponse = Record<string, Record<string, unknown>>;
|
||||
|
||||
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;
|
||||
|
||||
180
web/src/components/settings/AdminModules.tsx
Normal file
180
web/src/components/settings/AdminModules.tsx
Normal file
@@ -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<Record<string, ModuleInfo> | null>(
|
||||
null,
|
||||
);
|
||||
const [settings, setSettings] = useState<AdminSettingsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [restartRequired, setRestartRequired] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
get<ModulesResponse>("/api/modules"),
|
||||
get<AdminSettingsResponse>("/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<Record<string, unknown>>(`/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<string, ModuleInfo> = {
|
||||
...prev,
|
||||
[moduleId]: { ...prev[moduleId], enabled },
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={sectionTitleStyle}>Module Configuration</h3>
|
||||
<p style={{ color: "var(--ctp-overlay0)" }}>Loading modules...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={sectionTitleStyle}>Module Configuration</h3>
|
||||
<p style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
<div style={groupTitleStyle}>{title}</div>
|
||||
{available.map((id) => {
|
||||
const meta = modules[id];
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<ModuleCard
|
||||
key={id}
|
||||
moduleId={id}
|
||||
meta={meta}
|
||||
settings={settings[id] ?? {}}
|
||||
allModules={modules}
|
||||
onSaved={handleSaved}
|
||||
onToggled={handleToggled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={sectionTitleStyle}>Module Configuration</h3>
|
||||
|
||||
{restartRequired && (
|
||||
<div style={restartBannerStyle}>
|
||||
<span style={{ fontWeight: 600 }}>Restart required</span>
|
||||
<span>Some changes require a server restart to take effect.</span>
|
||||
<button
|
||||
onClick={() => setRestartRequired(false)}
|
||||
style={dismissBtnStyle}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderGroup("Infrastructure", infraModules)}
|
||||
{renderGroup("Features", featureModules)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
};
|
||||
655
web/src/components/settings/ModuleCard.tsx
Normal file
655
web/src/components/settings/ModuleCard.tsx
Normal file
@@ -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<string, unknown>;
|
||||
allModules: Record<string, ModuleInfo>;
|
||||
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<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestConnectivityResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
|
||||
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<UpdateSettingsResponse>(
|
||||
`/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<UpdateSettingsResponse>(
|
||||
`/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<TestConnectivityResponse>(
|
||||
`/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 <span style={badgeStyles.disabled}>Disabled</span>;
|
||||
if (status === "unavailable")
|
||||
return <span style={badgeStyles.error}>Error</span>;
|
||||
return <span style={badgeStyles.active}>Active</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={headerStyle}
|
||||
onClick={() => hasFields && setExpanded(!expanded)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
{!meta.required && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle();
|
||||
}}
|
||||
disabled={toggling}
|
||||
style={{
|
||||
...toggleBtnStyle,
|
||||
backgroundColor: enabled
|
||||
? "var(--ctp-green)"
|
||||
: "var(--ctp-surface2)",
|
||||
}}
|
||||
title={enabled ? "Disable module" : "Enable module"}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...toggleKnobStyle,
|
||||
transform: enabled ? "translateX(14px)" : "translateX(0)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontWeight: 600, fontSize: "var(--font-title)" }}>
|
||||
{meta.name}
|
||||
</span>
|
||||
{statusBadge()}
|
||||
</div>
|
||||
{hasFields && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-overlay1)",
|
||||
fontSize: "0.75rem",
|
||||
transition: "transform 0.15s ease",
|
||||
transform: expanded ? "rotate(180deg)" : "rotate(0)",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle error */}
|
||||
{toggleError && (
|
||||
<div style={{ ...errorStyle, margin: "0.5rem 1.5rem 0" }}>
|
||||
{toggleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies note */}
|
||||
{deps.length > 0 && expanded && (
|
||||
<div style={depNoteStyle}>
|
||||
Requires:{" "}
|
||||
{deps.map((d) => allModules[d]?.name ?? d).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{expanded && hasFields && (
|
||||
<div style={bodyStyle}>
|
||||
{renderModuleFields(moduleId, settings, getFieldValue, setField)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={footerStyle}>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={btnPrimaryStyle}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
)}
|
||||
{isTestable && (
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
style={btnSecondaryStyle}
|
||||
>
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{saveSuccess && (
|
||||
<span style={{ color: "var(--ctp-green)", fontSize: "var(--font-body)" }}>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{saveError && (
|
||||
<span style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}>
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div
|
||||
style={{
|
||||
...testResultStyle,
|
||||
borderColor: testResult.success
|
||||
? "rgba(166, 227, 161, 0.3)"
|
||||
: "rgba(243, 139, 168, 0.3)",
|
||||
background: testResult.success
|
||||
? "rgba(166, 227, 161, 0.08)"
|
||||
: "rgba(243, 139, 168, 0.08)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: testResult.success
|
||||
? "var(--ctp-green)"
|
||||
: "var(--ctp-red)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{testResult.success ? "OK" : "Failed"}
|
||||
</span>
|
||||
<span style={{ color: "var(--ctp-subtext0)", fontSize: "var(--font-body)" }}>
|
||||
{testResult.message}
|
||||
</span>
|
||||
{testResult.latency_ms > 0 && (
|
||||
<span style={{ color: "var(--ctp-overlay1)", fontSize: "var(--font-body)" }}>
|
||||
{testResult.latency_ms}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Field renderers per module ---
|
||||
|
||||
function renderModuleFields(
|
||||
moduleId: string,
|
||||
settings: Record<string, unknown>,
|
||||
getValue: (key: string) => unknown,
|
||||
setValue: (key: string, value: unknown) => void,
|
||||
) {
|
||||
switch (moduleId) {
|
||||
case "core":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Host" value={settings.host} />
|
||||
<ReadOnlyField label="Port" value={settings.port} />
|
||||
<ReadOnlyField label="Base URL" value={settings.base_url} />
|
||||
<ReadOnlyField
|
||||
label="Read Only"
|
||||
value={settings.readonly ? "Yes" : "No"}
|
||||
/>
|
||||
</FieldGrid>
|
||||
);
|
||||
case "schemas":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Directory" value={settings.directory} />
|
||||
<ReadOnlyField label="Default" value={settings.default} />
|
||||
<ReadOnlyField label="Schema Count" value={settings.count} />
|
||||
</FieldGrid>
|
||||
);
|
||||
case "database":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Host" value={settings.host} />
|
||||
<ReadOnlyField label="Port" value={settings.port} />
|
||||
<ReadOnlyField label="Database" value={settings.name} />
|
||||
<ReadOnlyField label="User" value={settings.user} />
|
||||
<ReadOnlyField label="SSL Mode" value={settings.sslmode} />
|
||||
<ReadOnlyField label="Max Connections" value={settings.max_connections} />
|
||||
</FieldGrid>
|
||||
);
|
||||
case "storage":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Endpoint" value={settings.endpoint} />
|
||||
<ReadOnlyField label="Bucket" value={settings.bucket} />
|
||||
<ReadOnlyField label="SSL" value={settings.use_ssl ? "Yes" : "No"} />
|
||||
<ReadOnlyField label="Region" value={settings.region} />
|
||||
</FieldGrid>
|
||||
);
|
||||
case "auth":
|
||||
return renderAuthFields(settings);
|
||||
case "freecad":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<EditableField
|
||||
label="URI Scheme"
|
||||
value={getValue("uri_scheme")}
|
||||
onChange={(v) => setValue("uri_scheme", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Executable"
|
||||
value={getValue("executable")}
|
||||
onChange={(v) => setValue("executable", v)}
|
||||
/>
|
||||
</FieldGrid>
|
||||
);
|
||||
case "odoo":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<EditableField
|
||||
label="URL"
|
||||
value={getValue("url")}
|
||||
onChange={(v) => setValue("url", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Database"
|
||||
value={getValue("database")}
|
||||
onChange={(v) => setValue("database", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Username"
|
||||
value={getValue("username")}
|
||||
onChange={(v) => setValue("username", v)}
|
||||
/>
|
||||
</FieldGrid>
|
||||
);
|
||||
case "jobs":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<EditableField
|
||||
label="Definitions Directory"
|
||||
value={getValue("directory")}
|
||||
onChange={(v) => setValue("directory", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Runner Timeout (s)"
|
||||
value={getValue("runner_timeout")}
|
||||
onChange={(v) => setValue("runner_timeout", Number(v))}
|
||||
type="number"
|
||||
/>
|
||||
<EditableField
|
||||
label="Timeout Check (s)"
|
||||
value={getValue("job_timeout_check")}
|
||||
onChange={(v) => setValue("job_timeout_check", Number(v))}
|
||||
type="number"
|
||||
/>
|
||||
<EditableField
|
||||
label="Default Priority"
|
||||
value={getValue("default_priority")}
|
||||
onChange={(v) => setValue("default_priority", Number(v))}
|
||||
type="number"
|
||||
/>
|
||||
</FieldGrid>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAuthFields(settings: Record<string, unknown>) {
|
||||
const local = (settings.local ?? {}) as Record<string, unknown>;
|
||||
const ldap = (settings.ldap ?? {}) as Record<string, unknown>;
|
||||
const oidc = (settings.oidc ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<SubSection title="Local Auth">
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Enabled" value={local.enabled ? "Yes" : "No"} />
|
||||
<ReadOnlyField label="Default Admin" value={local.default_admin_username} />
|
||||
</FieldGrid>
|
||||
</SubSection>
|
||||
<SubSection title="LDAP / FreeIPA">
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Enabled" value={ldap.enabled ? "Yes" : "No"} />
|
||||
<ReadOnlyField label="URL" value={ldap.url} />
|
||||
<ReadOnlyField label="Base DN" value={ldap.base_dn} />
|
||||
<ReadOnlyField label="Bind DN" value={ldap.bind_dn} />
|
||||
</FieldGrid>
|
||||
</SubSection>
|
||||
<SubSection title="OIDC / Keycloak">
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Enabled" value={oidc.enabled ? "Yes" : "No"} />
|
||||
<ReadOnlyField label="Issuer URL" value={oidc.issuer_url} />
|
||||
<ReadOnlyField label="Client ID" value={oidc.client_id} />
|
||||
<ReadOnlyField label="Redirect URL" value={oidc.redirect_url} />
|
||||
</FieldGrid>
|
||||
</SubSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared field components ---
|
||||
|
||||
function FieldGrid({ children }: { children: React.ReactNode }) {
|
||||
return <div style={fieldGridStyle}>{children}</div>;
|
||||
}
|
||||
|
||||
function SubSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div style={subSectionTitleStyle}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: unknown;
|
||||
}) {
|
||||
const display =
|
||||
value === undefined || value === null || value === ""
|
||||
? "—"
|
||||
: String(value);
|
||||
return (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{label}</div>
|
||||
<div style={fieldValueStyle}>{display}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{label}</div>
|
||||
<input
|
||||
type={type}
|
||||
value={isRedacted ? "" : strVal}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={isRedacted ? "••••••••" : undefined}
|
||||
className="silo-input"
|
||||
style={fieldInputStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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",
|
||||
};
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin: Module Configuration */}
|
||||
{user?.role === "admin" && <AdminModules />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user