15 Commits

Author SHA1 Message Date
a6267ba3d5 Merge pull request 'feat: admin settings API — GET/PUT settings, test connectivity' (#104) from feat-admin-settings-api into feat-module-system
Reviewed-on: #104
2026-02-15 08:48:04 +00:00
Forbes
c98eed0b13 test(api): admin settings handler tests
- TestGetAllSettings — all module keys present, secrets redacted
- TestGetModuleSettings — single module response
- TestGetModuleSettings_Unknown — 404 for unknown module
- TestToggleModule — disable projects, verify registry state
- TestToggleModule_DependencyError — enable dag without jobs, expect 400
- TestToggleRequiredModule — disable core, expect 400
- TestTestConnectivity_Database — ping database, expect success
- TestTestConnectivity_NotTestable — core module, expect 400
2026-02-14 15:15:55 -06:00
Forbes
ca71153c15 feat(api): admin settings API endpoints
Add four admin-only endpoints under /api/admin/settings:

- GET  /                — full config (secrets redacted)
- GET  /{module}        — single module config
- PUT  /{module}        — toggle modules + persist config overrides
- POST /{module}/test   — test external connectivity (database, storage)

PUT publishes a settings.changed SSE event. Config overrides are
persisted for future hot-reload support; changes to database/storage/
server/schemas namespaces return restart_required: true.

Wires SettingsRepository into Server struct.

Closes #99
2026-02-14 15:15:48 -06:00
Forbes
6e49fade8b feat(db): add SettingsRepository for module state and config overrides
Provides CRUD operations on the module_state and settings_overrides
tables (created in migration 016).

- GetModuleStates / SetModuleState — upsert module enabled/disabled
- GetOverrides / SetOverride / DeleteOverride — JSONB config overrides

Part of #99
2026-02-14 15:15:39 -06:00
Forbes
138ce16010 fix: remove unreachable code in testutil.findProjectRoot 2026-02-14 14:02:48 -06:00
Forbes
690ad73161 feat(modules): public GET /api/modules discovery endpoint
Add HandleGetModules returning module state, metadata, and
public config (auth providers, Create URI scheme). No auth
required — clients call this pre-login.

Register at /api/modules before the auth middleware.

Ref #97
2026-02-14 14:02:11 -06:00
Forbes
b8abd8859d feat(modules): RequireModule middleware to gate route groups
Add RequireModule middleware that returns 404 with
{"error":"module '<id>' is not enabled"} when a module is disabled.

Wrap route groups:
- projects → RequireModule("projects")
- audit → RequireModule("audit")
- integrations/odoo → RequireModule("odoo")
- jobs, job-definitions, runners → RequireModule("jobs")
- /api/runner (runner-facing) → RequireModule("jobs")
- dag → RequireModule("dag") (extracted into sub-route)

Ref #98
2026-02-14 14:01:32 -06:00
Forbes
4fd4013360 feat(modules): wire registry into server startup
Add modules.Registry and config.Config fields to Server struct.
Create registry in main.go, load state from YAML+DB, log all
module states at startup.

Conditionally start job/runner sweeper goroutines only when the
jobs module is enabled.

Update all 5 test files to pass registry to NewServer.

Ref #95, #96
2026-02-14 14:00:24 -06:00
Forbes
3adc155b14 feat(modules): config loader refactor — YAML → DB → env pipeline
Add ModulesConfig and ModuleToggle types to config.go for explicit
module enable/disable in YAML.

Add LoadState() that merges state from three sources:
1. Backward-compat YAML fields (auth.enabled, odoo.enabled)
2. Explicit modules.* YAML toggles (override compat)
3. Database module_state table (highest precedence)

Validates dependency chain after loading. 5 loader tests.

Ref #95
2026-02-14 13:58:26 -06:00
Forbes
9d8afa5981 feat(modules): module registry with metadata, dependencies, and defaults
In-memory registry for 10 modules (3 required, 7 optional).
SetEnabled validates dependency chains: cannot enable a module
whose dependencies are disabled, cannot disable a module that
others depend on.

9 unit tests covering default state, toggling, dependency
validation, and error cases.

Ref #96
2026-02-14 13:57:32 -06:00
Forbes
f91cf2bc6f feat(modules): settings_overrides and module_state migration
Add migration 016 with two tables for the module system:
- settings_overrides: dotted-path config overrides set via admin UI
- module_state: per-module enabled/disabled state

Update testutil.TruncateAll to include new tables.

Ref #94
2026-02-14 13:56:26 -06:00
ef44523ae8 Merge pull request 'fix(web): standardize typography and spacing to style guide' (#93) from fix-web-style-guide into main
Reviewed-on: #93
2026-02-14 19:37:04 +00:00
Forbes
ba92dd363c fix(web): align all spacing values to 4px grid
Standardize all spacing to multiples of 4px (0.25rem):
- 0.15rem (2.4px) → 0.25rem (4px)
- 0.35rem (5.6px) → 0.25rem (4px)
- 0.375rem (6px) → 0.25rem (4px) for borderRadius
- 0.4rem (6.4px) → 0.5rem (8px)
- 0.6rem (9.6px) → 0.5rem (8px)

Updated theme.css density variables, silo-base.css focus ring,
and all TSX component inline styles.

Closes #71
2026-02-14 13:36:22 -06:00
Forbes
c7857fdfc9 fix(web): standardize font sizes to style guide scale
Map fontWeight: 700 → 600 in non-title contexts (LoginPage, FileDropZone).
Align FileDropZone badge padding to 4px grid.

Closes #70
2026-02-14 13:36:07 -06:00
defb3af56f Merge pull request 'feat: dependency DAG and YAML-defined compute jobs' (#92) from feat-dag-workers into main
Reviewed-on: #92
2026-02-14 19:27:18 +00:00
46 changed files with 1594 additions and 212 deletions

View File

@@ -20,6 +20,7 @@ import (
"github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/jobdef"
"github.com/kindredsystems/silo/internal/modules"
"github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/storage"
"github.com/rs/zerolog"
@@ -219,6 +220,16 @@ func main() {
}
}
// Initialize module registry
registry := modules.NewRegistry()
if err := modules.LoadState(registry, cfg, database.Pool()); err != nil {
logger.Fatal().Err(err).Msg("failed to load module state")
}
for _, m := range registry.All() {
logger.Info().Str("module", m.ID).Bool("enabled", registry.IsEnabled(m.ID)).
Bool("required", m.Required).Msg("module")
}
// Create SSE broker and server state
broker := api.NewBroker(logger)
serverState := api.NewServerState(logger, store, broker)
@@ -232,27 +243,30 @@ func main() {
// Create API server
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
authService, sessionManager, oidcBackend, &cfg.Auth, broker, serverState,
jobDefs, cfg.Jobs.Directory)
jobDefs, cfg.Jobs.Directory, registry, cfg)
router := api.NewRouter(server, logger)
// Start background sweepers for job/runner timeouts
go func() {
ticker := time.NewTicker(time.Duration(cfg.Jobs.JobTimeoutCheck) * time.Second)
defer ticker.Stop()
for range ticker.C {
if n, err := jobRepo.TimeoutExpiredJobs(ctx); err != nil {
logger.Error().Err(err).Msg("job timeout sweep failed")
} else if n > 0 {
logger.Info().Int64("count", n).Msg("timed out expired jobs")
}
// Start background sweepers for job/runner timeouts (only when jobs module enabled)
if registry.IsEnabled(modules.Jobs) {
go func() {
ticker := time.NewTicker(time.Duration(cfg.Jobs.JobTimeoutCheck) * time.Second)
defer ticker.Stop()
for range ticker.C {
if n, err := jobRepo.TimeoutExpiredJobs(ctx); err != nil {
logger.Error().Err(err).Msg("job timeout sweep failed")
} else if n > 0 {
logger.Info().Int64("count", n).Msg("timed out expired jobs")
}
if n, err := jobRepo.ExpireStaleRunners(ctx, time.Duration(cfg.Jobs.RunnerTimeout)*time.Second); err != nil {
logger.Error().Err(err).Msg("runner expiry sweep failed")
} else if n > 0 {
logger.Info().Int64("count", n).Msg("expired stale runners")
if n, err := jobRepo.ExpireStaleRunners(ctx, time.Duration(cfg.Jobs.RunnerTimeout)*time.Second); err != nil {
logger.Error().Err(err).Msg("runner expiry sweep failed")
} else if n > 0 {
logger.Info().Int64("count", n).Msg("expired stale runners")
}
}
}
}()
}()
logger.Info().Msg("job/runner sweepers started")
}
// Create HTTP server
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)

View File

@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/auth"
"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"
@@ -38,8 +39,10 @@ func newAuthTestServer(t *testing.T) *Server {
nil, // authConfig
broker,
state,
nil, // jobDefs
"", // jobDefsDir
nil, // jobDefs
"", // jobDefsDir
modules.NewRegistry(), // modules
nil, // cfg
)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/auth"
"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"
@@ -35,8 +36,10 @@ func newTestServer(t *testing.T) *Server {
nil, // authConfig (nil = dev mode)
broker,
state,
nil, // jobDefs
"", // jobDefsDir
nil, // jobDefs
"", // jobDefsDir
modules.NewRegistry(), // modules
nil, // cfg
)
}

View File

@@ -13,6 +13,7 @@ import (
"testing"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/modules"
"github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/testutil"
"github.com/rs/zerolog"
@@ -64,8 +65,10 @@ func newTestServerWithSchemas(t *testing.T) *Server {
nil, // authConfig
broker,
state,
nil, // jobDefs
"", // jobDefsDir
nil, // jobDefs
"", // jobDefsDir
modules.NewRegistry(), // modules
nil, // cfg
)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"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"
@@ -29,6 +30,7 @@ func newDAGTestServer(t *testing.T) *Server {
nil, nil, nil, nil, nil,
broker, state,
nil, "",
modules.NewRegistry(), nil,
)
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/jobdef"
"github.com/kindredsystems/silo/internal/modules"
"github.com/kindredsystems/silo/internal/partnum"
"github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/storage"
@@ -48,6 +49,9 @@ type Server struct {
jobs *db.JobRepository
jobDefs map[string]*jobdef.Definition
jobDefsDir string
modules *modules.Registry
cfg *config.Config
settings *db.SettingsRepository
}
// NewServer creates a new API server.
@@ -65,6 +69,8 @@ func NewServer(
state *ServerState,
jobDefs map[string]*jobdef.Definition,
jobDefsDir string,
registry *modules.Registry,
cfg *config.Config,
) *Server {
items := db.NewItemRepository(database)
projects := db.NewProjectRepository(database)
@@ -72,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)
@@ -96,6 +103,9 @@ func NewServer(
jobs: jobs,
jobDefs: jobDefs,
jobDefsDir: jobDefsDir,
modules: registry,
cfg: cfg,
settings: settings,
}
}
@@ -166,6 +176,54 @@ func (s *Server) HandleReady(w http.ResponseWriter, r *http.Request) {
})
}
// HandleGetModules returns the public module discovery response.
// No authentication required — clients call this pre-login.
func (s *Server) HandleGetModules(w http.ResponseWriter, r *http.Request) {
mods := make(map[string]any, 10)
for _, m := range s.modules.All() {
entry := map[string]any{
"enabled": s.modules.IsEnabled(m.ID),
"required": m.Required,
"name": m.Name,
}
if m.Version != "" {
entry["version"] = m.Version
}
if len(m.DependsOn) > 0 {
entry["depends_on"] = m.DependsOn
}
// Public config (non-secret) for specific modules.
switch m.ID {
case "auth":
if s.cfg != nil {
entry["config"] = map[string]any{
"local_enabled": s.cfg.Auth.Local.Enabled,
"ldap_enabled": s.cfg.Auth.LDAP.Enabled,
"oidc_enabled": s.cfg.Auth.OIDC.Enabled,
"oidc_issuer_url": s.cfg.Auth.OIDC.IssuerURL,
}
}
case "freecad":
if s.cfg != nil {
entry["config"] = map[string]any{
"uri_scheme": s.cfg.FreeCAD.URIScheme,
}
}
}
mods[m.ID] = entry
}
writeJSON(w, http.StatusOK, map[string]any{
"modules": mods,
"server": map[string]any{
"version": "0.2",
"read_only": s.serverState.IsReadOnly(),
},
})
}
// Schema handlers
// SchemaResponse represents a schema in API responses.

View File

@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"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"
@@ -29,6 +30,7 @@ func newJobTestServer(t *testing.T) *Server {
nil, nil, nil, nil, nil,
broker, state,
nil, "",
modules.NewRegistry(), nil,
)
}

View File

@@ -183,6 +183,22 @@ func (s *Server) RequireRunnerAuth(next http.Handler) http.Handler {
})
}
// RequireModule returns middleware that rejects requests with 404 when
// the named module is not enabled.
func (s *Server) RequireModule(id string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.modules.IsEnabled(id) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"module '` + id + `' is not enabled"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
func extractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {

View File

@@ -58,6 +58,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/auth/callback", server.HandleOIDCCallback)
// Public API endpoints (no auth required)
r.Get("/api/modules", server.HandleGetModules)
r.Get("/api/auth/config", server.HandleAuthConfig)
// API routes (require auth, no CSRF — token auth instead)
@@ -101,6 +102,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
// Projects (read: viewer, write: editor)
r.Route("/projects", func(r chi.Router) {
r.Use(server.RequireModule("projects"))
r.Get("/", server.HandleListProjects)
r.Get("/{code}", server.HandleGetProject)
r.Get("/{code}/items", server.HandleGetProjectItems)
@@ -150,10 +152,20 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
r.Get("/bom/export.ods", server.HandleExportBOMODS)
// DAG (read: viewer, write: editor)
r.Get("/dag", server.HandleGetDAG)
r.Get("/dag/forward-cone/{nodeKey}", server.HandleGetForwardCone)
r.Get("/dag/dirty", server.HandleGetDirtySubgraph)
// DAG (gated by dag module)
r.Route("/dag", func(r chi.Router) {
r.Use(server.RequireModule("dag"))
r.Get("/", server.HandleGetDAG)
r.Get("/forward-cone/{nodeKey}", server.HandleGetForwardCone)
r.Get("/dirty", server.HandleGetDirtySubgraph)
r.Group(func(r chi.Router) {
r.Use(server.RequireWritable)
r.Use(server.RequireRole(auth.RoleEditor))
r.Put("/", server.HandleSyncDAG)
r.Post("/mark-dirty/{nodeKey}", server.HandleMarkDirty)
})
})
r.Group(func(r chi.Router) {
r.Use(server.RequireWritable)
@@ -174,20 +186,20 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Post("/bom/merge", server.HandleMergeBOM)
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
r.Put("/dag", server.HandleSyncDAG)
r.Post("/dag/mark-dirty/{nodeKey}", server.HandleMarkDirty)
})
})
})
// Audit (read-only, viewer role)
r.Route("/audit", func(r chi.Router) {
r.Use(server.RequireModule("audit"))
r.Get("/completeness", server.HandleAuditCompleteness)
r.Get("/completeness/{partNumber}", server.HandleAuditItemDetail)
})
// Integrations (read: viewer, write: editor)
r.Route("/integrations/odoo", func(r chi.Router) {
r.Use(server.RequireModule("odoo"))
r.Get("/config", server.HandleGetOdooConfig)
r.Get("/sync-log", server.HandleGetOdooSyncLog)
@@ -210,6 +222,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
// Jobs (read: viewer, write: editor)
r.Route("/jobs", func(r chi.Router) {
r.Use(server.RequireModule("jobs"))
r.Get("/", server.HandleListJobs)
r.Get("/{jobID}", server.HandleGetJob)
r.Get("/{jobID}/logs", server.HandleGetJobLogs)
@@ -224,6 +237,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
// Job definitions (read: viewer, reload: admin)
r.Route("/job-definitions", func(r chi.Router) {
r.Use(server.RequireModule("jobs"))
r.Get("/", server.HandleListJobDefinitions)
r.Get("/{name}", server.HandleGetJobDefinition)
@@ -235,6 +249,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
// Runners (admin)
r.Route("/runners", func(r chi.Router) {
r.Use(server.RequireModule("jobs"))
r.Use(server.RequireRole(auth.RoleAdmin))
r.Get("/", server.HandleListRunners)
r.Post("/", server.HandleRegisterRunner)
@@ -247,10 +262,20 @@ 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)
r.Route("/api/runner", func(r chi.Router) {
r.Use(server.RequireModule("jobs"))
r.Use(server.RequireRunnerAuth)
r.Post("/heartbeat", server.HandleRunnerHeartbeat)
r.Post("/claim", server.HandleRunnerClaim)

View 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),
}
}

View 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())
}
}

View File

@@ -18,6 +18,25 @@ type Config struct {
Odoo OdooConfig `yaml:"odoo"`
Auth AuthConfig `yaml:"auth"`
Jobs JobsConfig `yaml:"jobs"`
Modules ModulesConfig `yaml:"modules"`
}
// ModulesConfig holds explicit enable/disable toggles for optional modules.
// A nil pointer means "use the module's default state".
type ModulesConfig struct {
Auth *ModuleToggle `yaml:"auth"`
Projects *ModuleToggle `yaml:"projects"`
Audit *ModuleToggle `yaml:"audit"`
Odoo *ModuleToggle `yaml:"odoo"`
FreeCAD *ModuleToggle `yaml:"freecad"`
Jobs *ModuleToggle `yaml:"jobs"`
DAG *ModuleToggle `yaml:"dag"`
}
// ModuleToggle holds an optional enabled flag. The pointer allows
// distinguishing "not set" (nil) from "explicitly false".
type ModuleToggle struct {
Enabled *bool `yaml:"enabled"`
}
// AuthConfig holds authentication and authorization settings.

105
internal/db/settings.go Normal file
View 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
}

View File

@@ -0,0 +1,84 @@
package modules
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kindredsystems/silo/internal/config"
)
// LoadState applies module state from config YAML and database overrides.
//
// Precedence (highest wins):
// 1. Database module_state table
// 2. YAML modules.* toggles
// 3. Backward-compat YAML fields (auth.enabled, odoo.enabled)
// 4. Module defaults (set by NewRegistry)
func LoadState(r *Registry, cfg *config.Config, pool *pgxpool.Pool) error {
// Step 1: Apply backward-compat top-level YAML fields.
// auth.enabled and odoo.enabled existed before the modules section.
// Only apply if the new modules.* section doesn't override them.
if cfg.Modules.Auth == nil {
r.setEnabledUnchecked(Auth, cfg.Auth.Enabled)
}
if cfg.Modules.Odoo == nil {
r.setEnabledUnchecked(Odoo, cfg.Odoo.Enabled)
}
// Step 2: Apply explicit modules.* YAML toggles (override defaults + compat).
applyToggle(r, Auth, cfg.Modules.Auth)
applyToggle(r, Projects, cfg.Modules.Projects)
applyToggle(r, Audit, cfg.Modules.Audit)
applyToggle(r, Odoo, cfg.Modules.Odoo)
applyToggle(r, FreeCAD, cfg.Modules.FreeCAD)
applyToggle(r, Jobs, cfg.Modules.Jobs)
applyToggle(r, DAG, cfg.Modules.DAG)
// Step 3: Apply database overrides (highest precedence).
if pool != nil {
if err := loadFromDB(r, pool); err != nil {
return err
}
}
// Step 4: Validate the final state.
return r.ValidateDependencies()
}
// applyToggle sets a module's state from a YAML ModuleToggle if present.
func applyToggle(r *Registry, id string, toggle *config.ModuleToggle) {
if toggle == nil || toggle.Enabled == nil {
return
}
r.setEnabledUnchecked(id, *toggle.Enabled)
}
// setEnabledUnchecked sets module state without dependency validation.
// Used during loading when the full state is being assembled incrementally.
func (r *Registry) setEnabledUnchecked(id string, enabled bool) {
r.mu.Lock()
defer r.mu.Unlock()
if m, ok := r.modules[id]; ok && !m.Required {
m.enabled = enabled
}
}
// loadFromDB reads module_state rows and applies them to the registry.
func loadFromDB(r *Registry, pool *pgxpool.Pool) error {
rows, err := pool.Query(context.Background(),
`SELECT module_id, enabled FROM module_state`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
var enabled bool
if err := rows.Scan(&id, &enabled); err != nil {
return err
}
r.setEnabledUnchecked(id, enabled)
}
return rows.Err()
}

View File

@@ -0,0 +1,88 @@
package modules
import (
"testing"
"github.com/kindredsystems/silo/internal/config"
)
func boolPtr(v bool) *bool { return &v }
func TestLoadState_DefaultsOnly(t *testing.T) {
r := NewRegistry()
cfg := &config.Config{}
if err := LoadState(r, cfg, nil); err != nil {
t.Fatalf("LoadState: %v", err)
}
// Auth defaults to true from registry, but cfg.Auth.Enabled is false
// (zero value) and backward-compat applies, so auth ends up disabled.
if r.IsEnabled(Auth) {
t.Error("auth should be disabled (cfg.Auth.Enabled is false by default)")
}
}
func TestLoadState_BackwardCompat(t *testing.T) {
r := NewRegistry()
cfg := &config.Config{}
cfg.Auth.Enabled = true
cfg.Odoo.Enabled = true
if err := LoadState(r, cfg, nil); err != nil {
t.Fatalf("LoadState: %v", err)
}
if !r.IsEnabled(Auth) {
t.Error("auth should be enabled via cfg.Auth.Enabled")
}
if !r.IsEnabled(Odoo) {
t.Error("odoo should be enabled via cfg.Odoo.Enabled")
}
}
func TestLoadState_YAMLModulesOverrideCompat(t *testing.T) {
r := NewRegistry()
cfg := &config.Config{}
cfg.Auth.Enabled = true // compat says enabled
cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled
if err := LoadState(r, cfg, nil); err != nil {
t.Fatalf("LoadState: %v", err)
}
if r.IsEnabled(Auth) {
t.Error("modules.auth.enabled=false should override auth.enabled=true")
}
}
func TestLoadState_EnableJobsAndDAG(t *testing.T) {
r := NewRegistry()
cfg := &config.Config{}
cfg.Auth.Enabled = true
cfg.Modules.Jobs = &config.ModuleToggle{Enabled: boolPtr(true)}
cfg.Modules.DAG = &config.ModuleToggle{Enabled: boolPtr(true)}
if err := LoadState(r, cfg, nil); err != nil {
t.Fatalf("LoadState: %v", err)
}
if !r.IsEnabled(Jobs) {
t.Error("jobs should be enabled")
}
if !r.IsEnabled(DAG) {
t.Error("dag should be enabled")
}
}
func TestLoadState_InvalidDependency(t *testing.T) {
r := NewRegistry()
cfg := &config.Config{}
// Auth disabled (default), but enable jobs which depends on auth.
cfg.Modules.Jobs = &config.ModuleToggle{Enabled: boolPtr(true)}
err := LoadState(r, cfg, nil)
if err == nil {
t.Error("should fail: jobs enabled but auth disabled")
}
}

163
internal/modules/modules.go Normal file
View File

@@ -0,0 +1,163 @@
// Package modules provides the module registry for Silo.
// Each module groups API endpoints, UI views, and configuration.
// Modules can be required (always on) or optional (admin-toggleable).
package modules
import (
"fmt"
"sort"
"sync"
)
// Module IDs.
const (
Core = "core"
Schemas = "schemas"
Storage = "storage"
Auth = "auth"
Projects = "projects"
Audit = "audit"
Odoo = "odoo"
FreeCAD = "freecad"
Jobs = "jobs"
DAG = "dag"
)
// ModuleInfo describes a module's metadata.
type ModuleInfo struct {
ID string
Name string
Description string
Required bool // cannot be disabled
DefaultEnabled bool // initial state for optional modules
DependsOn []string // module IDs this module requires
Version string
}
// registry entries with their runtime enabled state.
type moduleState struct {
ModuleInfo
enabled bool
}
// Registry holds all module definitions and their enabled state.
type Registry struct {
mu sync.RWMutex
modules map[string]*moduleState
}
// builtinModules defines the complete set of Silo modules.
var builtinModules = []ModuleInfo{
{ID: Core, Name: "Core PDM", Description: "Items, revisions, files, BOM, search, import/export", Required: true, Version: "0.2"},
{ID: Schemas, Name: "Schemas", Description: "Part numbering schema parsing and segment management", Required: true},
{ID: Storage, Name: "Storage", Description: "MinIO/S3 file storage, presigned uploads", Required: true},
{ID: Auth, Name: "Authentication", Description: "Local, LDAP, OIDC authentication and RBAC", DefaultEnabled: true},
{ID: Projects, Name: "Projects", Description: "Project management and item tagging", DefaultEnabled: true},
{ID: Audit, Name: "Audit", Description: "Audit logging, completeness scoring", DefaultEnabled: true},
{ID: Odoo, Name: "Odoo ERP", Description: "Odoo integration (config, sync-log, push/pull)", DependsOn: []string{Auth}},
{ID: FreeCAD, Name: "Create Integration", Description: "URI scheme, executable path, client settings", DefaultEnabled: true},
{ID: Jobs, Name: "Job Queue", Description: "Async compute jobs, runner management", DependsOn: []string{Auth}},
{ID: DAG, Name: "Dependency DAG", Description: "Feature DAG sync, validation states, interference detection", DependsOn: []string{Jobs}},
}
// NewRegistry creates a registry with all builtin modules set to their default state.
func NewRegistry() *Registry {
r := &Registry{modules: make(map[string]*moduleState, len(builtinModules))}
for _, m := range builtinModules {
enabled := m.Required || m.DefaultEnabled
r.modules[m.ID] = &moduleState{ModuleInfo: m, enabled: enabled}
}
return r
}
// IsEnabled returns whether a module is currently enabled.
func (r *Registry) IsEnabled(id string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if m, ok := r.modules[id]; ok {
return m.enabled
}
return false
}
// SetEnabled changes a module's enabled state with dependency validation.
func (r *Registry) SetEnabled(id string, enabled bool) error {
r.mu.Lock()
defer r.mu.Unlock()
m, ok := r.modules[id]
if !ok {
return fmt.Errorf("unknown module %q", id)
}
if m.Required {
return fmt.Errorf("module %q is required and cannot be disabled", id)
}
if enabled {
// Check that all dependencies are enabled.
for _, dep := range m.DependsOn {
if dm, ok := r.modules[dep]; ok && !dm.enabled {
return fmt.Errorf("cannot enable %q: dependency %q is disabled", id, dep)
}
}
} else {
// Check that no enabled module depends on this one.
for _, other := range r.modules {
if !other.enabled || other.ID == id {
continue
}
for _, dep := range other.DependsOn {
if dep == id {
return fmt.Errorf("cannot disable %q: module %q depends on it", id, other.ID)
}
}
}
}
m.enabled = enabled
return nil
}
// All returns info for every module, sorted by ID.
func (r *Registry) All() []ModuleInfo {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]ModuleInfo, 0, len(r.modules))
for _, m := range r.modules {
out = append(out, m.ModuleInfo)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Get returns info for a single module, or nil if not found.
func (r *Registry) Get(id string) *ModuleInfo {
r.mu.RLock()
defer r.mu.RUnlock()
if m, ok := r.modules[id]; ok {
info := m.ModuleInfo
return &info
}
return nil
}
// ValidateDependencies checks that every enabled module's dependencies
// are also enabled. Returns the first violation found.
func (r *Registry) ValidateDependencies() error {
r.mu.RLock()
defer r.mu.RUnlock()
for _, m := range r.modules {
if !m.enabled {
continue
}
for _, dep := range m.DependsOn {
if dm, ok := r.modules[dep]; ok && !dm.enabled {
return fmt.Errorf("module %q is enabled but its dependency %q is disabled", m.ID, dep)
}
}
}
return nil
}

View File

@@ -0,0 +1,169 @@
package modules
import (
"testing"
)
func TestNewRegistry_DefaultState(t *testing.T) {
r := NewRegistry()
// Required modules are always enabled.
for _, id := range []string{Core, Schemas, Storage} {
if !r.IsEnabled(id) {
t.Errorf("required module %q should be enabled by default", id)
}
}
// Optional modules with DefaultEnabled=true.
for _, id := range []string{Auth, Projects, Audit, FreeCAD} {
if !r.IsEnabled(id) {
t.Errorf("module %q should be enabled by default", id)
}
}
// Optional modules with DefaultEnabled=false.
for _, id := range []string{Odoo, Jobs, DAG} {
if r.IsEnabled(id) {
t.Errorf("module %q should be disabled by default", id)
}
}
}
func TestSetEnabled_BasicToggle(t *testing.T) {
r := NewRegistry()
// Disable an optional module with no dependents.
if err := r.SetEnabled(Projects, false); err != nil {
t.Fatalf("disabling projects: %v", err)
}
if r.IsEnabled(Projects) {
t.Error("projects should be disabled after SetEnabled(false)")
}
// Re-enable it.
if err := r.SetEnabled(Projects, true); err != nil {
t.Fatalf("enabling projects: %v", err)
}
if !r.IsEnabled(Projects) {
t.Error("projects should be enabled after SetEnabled(true)")
}
}
func TestCannotDisableRequired(t *testing.T) {
r := NewRegistry()
for _, id := range []string{Core, Schemas, Storage} {
if err := r.SetEnabled(id, false); err == nil {
t.Errorf("disabling required module %q should return error", id)
}
}
}
func TestDependencyChain_EnableWithoutDep(t *testing.T) {
r := NewRegistry()
// Jobs depends on Auth. Auth is enabled by default, so enabling jobs works.
if err := r.SetEnabled(Jobs, true); err != nil {
t.Fatalf("enabling jobs (auth enabled): %v", err)
}
// DAG depends on Jobs. Jobs is now enabled, so enabling dag works.
if err := r.SetEnabled(DAG, true); err != nil {
t.Fatalf("enabling dag (jobs enabled): %v", err)
}
// Now try with deps disabled. Start fresh.
r2 := NewRegistry()
// DAG depends on Jobs, which is disabled by default.
if err := r2.SetEnabled(DAG, true); err == nil {
t.Error("enabling dag without jobs should fail")
}
}
func TestDisableDependedOn(t *testing.T) {
r := NewRegistry()
// Enable the full chain: auth (already on) → jobs → dag.
if err := r.SetEnabled(Jobs, true); err != nil {
t.Fatal(err)
}
if err := r.SetEnabled(DAG, true); err != nil {
t.Fatal(err)
}
// Cannot disable jobs while dag depends on it.
if err := r.SetEnabled(Jobs, false); err == nil {
t.Error("disabling jobs while dag is enabled should fail")
}
// Disable dag first, then jobs should work.
if err := r.SetEnabled(DAG, false); err != nil {
t.Fatal(err)
}
if err := r.SetEnabled(Jobs, false); err != nil {
t.Fatalf("disabling jobs after dag disabled: %v", err)
}
}
func TestCannotDisableAuthWhileJobsEnabled(t *testing.T) {
r := NewRegistry()
if err := r.SetEnabled(Jobs, true); err != nil {
t.Fatal(err)
}
// Auth is depended on by jobs.
if err := r.SetEnabled(Auth, false); err == nil {
t.Error("disabling auth while jobs is enabled should fail")
}
}
func TestUnknownModule(t *testing.T) {
r := NewRegistry()
if r.IsEnabled("nonexistent") {
t.Error("unknown module should not be enabled")
}
if err := r.SetEnabled("nonexistent", true); err == nil {
t.Error("setting unknown module should return error")
}
if r.Get("nonexistent") != nil {
t.Error("getting unknown module should return nil")
}
}
func TestAll_ReturnsAllModules(t *testing.T) {
r := NewRegistry()
all := r.All()
if len(all) != 10 {
t.Errorf("expected 10 modules, got %d", len(all))
}
// Should be sorted by ID.
for i := 1; i < len(all); i++ {
if all[i].ID < all[i-1].ID {
t.Errorf("modules not sorted: %s before %s", all[i-1].ID, all[i].ID)
}
}
}
func TestValidateDependencies(t *testing.T) {
r := NewRegistry()
// Default state should be valid.
if err := r.ValidateDependencies(); err != nil {
t.Fatalf("default state should be valid: %v", err)
}
// Force an invalid state by directly mutating (bypassing SetEnabled).
r.mu.Lock()
r.modules[Jobs].enabled = true
r.modules[Auth].enabled = false
r.mu.Unlock()
if err := r.ValidateDependencies(); err == nil {
t.Error("should detect jobs enabled without auth")
}
}

View File

@@ -3,7 +3,6 @@ package testutil
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
@@ -80,6 +79,7 @@ func TruncateAll(t *testing.T, pool *pgxpool.Pool) {
_, err := pool.Exec(context.Background(), `
TRUNCATE
settings_overrides, module_state,
job_log, jobs, job_definitions, runners,
dag_cross_edges, dag_edges, dag_nodes,
audit_log, sync_log, api_tokens, sessions, item_files,
@@ -111,6 +111,4 @@ func findProjectRoot(t *testing.T) string {
}
dir = parent
}
panic(fmt.Sprintf("unreachable"))
}

View File

@@ -0,0 +1,15 @@
-- 016_module_system.sql — settings overrides and module state persistence
CREATE TABLE IF NOT EXISTS settings_overrides (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_by TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_state (
module_id TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL,
updated_by TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@@ -100,7 +100,7 @@ export function AppShell() {
<span
style={{
display: "inline-block",
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 600,
@@ -115,7 +115,7 @@ export function AppShell() {
style={{
padding: "0.25rem 0.5rem",
fontSize: "var(--font-sm)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
cursor: "pointer",
border: "1px solid var(--ctp-surface1)",
background: "var(--ctp-surface0)",
@@ -129,9 +129,9 @@ export function AppShell() {
<button
onClick={logout}
style={{
padding: "0.35rem 0.75rem",
padding: "0.25rem 0.75rem",
fontSize: "var(--font-table)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
cursor: "pointer",
border: "none",
background: "var(--ctp-surface1)",

View File

@@ -80,7 +80,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
alignItems: "center",
gap: "0.5rem",
width: "100%",
padding: "0.35rem 0.75rem",
padding: "0.25rem 0.75rem",
background: "none",
border: "none",
color: item.disabled ? "var(--ctp-overlay0)" : "var(--ctp-text)",

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import type { ReactNode } from "react";
interface PageFooterProps {
stats?: ReactNode;
@@ -8,32 +8,40 @@ interface PageFooterProps {
onPageChange?: (page: number) => void;
}
export function PageFooter({ stats, page, pageSize, itemCount, onPageChange }: PageFooterProps) {
export function PageFooter({
stats,
page,
pageSize,
itemCount,
onPageChange,
}: PageFooterProps) {
const hasPagination = page !== undefined && onPageChange !== undefined;
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 'var(--d-footer-h)',
backgroundColor: 'var(--ctp-surface0)',
borderTop: '1px solid var(--ctp-surface1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 var(--d-footer-px)',
fontSize: 'var(--d-footer-font)',
color: 'var(--ctp-subtext0)',
zIndex: 100,
}}>
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'center' }}>
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
height: "var(--d-footer-h)",
backgroundColor: "var(--ctp-surface0)",
borderTop: "1px solid var(--ctp-surface1)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 var(--d-footer-px)",
fontSize: "var(--d-footer-font)",
color: "var(--ctp-subtext0)",
zIndex: 100,
}}
>
<div style={{ display: "flex", gap: "1.5rem", alignItems: "center" }}>
{stats}
</div>
{hasPagination && (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
@@ -47,7 +55,11 @@ export function PageFooter({ stats, page, pageSize, itemCount, onPageChange }: P
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={pageSize !== undefined && itemCount !== undefined && itemCount < pageSize}
disabled={
pageSize !== undefined &&
itemCount !== undefined &&
itemCount < pageSize
}
style={pageBtnStyle}
>
Next
@@ -59,11 +71,11 @@ export function PageFooter({ stats, page, pageSize, itemCount, onPageChange }: P
}
const pageBtnStyle: React.CSSProperties = {
padding: '0.15rem 0.4rem',
fontSize: 'inherit',
border: 'none',
borderRadius: '0.25rem',
backgroundColor: 'var(--ctp-surface1)',
color: 'var(--ctp-text)',
cursor: 'pointer',
padding: "0.25rem 0.5rem",
fontSize: "inherit",
border: "none",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
cursor: "pointer",
};

View File

@@ -124,7 +124,7 @@ export function TagInput({
padding: "0.25rem 0.5rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
cursor: "text",
minHeight: "1.8rem",
}}
@@ -137,7 +137,7 @@ export function TagInput({
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
backgroundColor: "rgba(203,166,247,0.15)",
color: "var(--ctp-mauve)",
@@ -187,7 +187,7 @@ export function TagInput({
background: "transparent",
color: "var(--ctp-text)",
fontSize: "var(--font-body)",
padding: "0.15rem 0",
padding: "0.25rem 0",
}}
/>
</div>
@@ -202,7 +202,7 @@ export function TagInput({
marginTop: "0.25rem",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
maxHeight: "160px",
overflowY: "auto",
}}

View File

@@ -218,7 +218,7 @@ export function AuditDetailPanel({
<span
style={{
display: "inline-block",
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 600,
@@ -477,10 +477,10 @@ function FieldRow({
placeholder="---"
style={{
flex: 1,
padding: "0.25rem 0.4rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-text)",
outline: "none",
@@ -495,7 +495,7 @@ const closeBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
cursor: "pointer",

View File

@@ -70,7 +70,7 @@ export function AuditSummaryBar({
style={{
display: "flex",
gap: "1.5rem",
marginTop: "0.4rem",
marginTop: "0.5rem",
fontSize: "var(--font-table)",
color: "var(--ctp-subtext0)",
}}

View File

@@ -103,7 +103,7 @@ export function AuditTable({
<span
style={{
display: "inline-block",
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 600,

View File

@@ -97,7 +97,7 @@ export function AuditToolbar({
const selectStyle: React.CSSProperties = {
padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "var(--d-input-font)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
border: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-text)",
@@ -106,7 +106,7 @@ const selectStyle: React.CSSProperties = {
const btnStyle: React.CSSProperties = {
padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "var(--d-input-font)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",

View File

@@ -118,11 +118,11 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
};
const inputStyle: React.CSSProperties = {
padding: "0.25rem 0.4rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
width: "100%",
};
@@ -240,7 +240,7 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
gap: "0.25rem",
}}
>
<Download size={14} /> Export CSV
@@ -256,7 +256,7 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
gap: "0.25rem",
}}
>
<Plus size={14} /> Add
@@ -267,9 +267,9 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
{isEditor && assemblyCount > 0 && (
<div
style={{
padding: "0.35rem 0.5rem",
padding: "0.25rem 0.5rem",
marginBottom: "0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "rgba(148,226,213,0.1)",
border: "1px solid rgba(148,226,213,0.3)",
fontSize: "0.75rem",
@@ -438,7 +438,7 @@ const toolBtnStyle: React.CSSProperties = {
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
cursor: "pointer",
@@ -451,16 +451,16 @@ const actionBtnStyle: React.CSSProperties = {
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.15rem 0.25rem",
borderRadius: "0.375rem",
padding: "0.25rem 0.25rem",
borderRadius: "0.25rem",
};
const saveBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.4rem",
padding: "0.25rem 0.5rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-green)",
color: "var(--ctp-crust)",
cursor: "pointer",
@@ -468,7 +468,7 @@ const saveBtnStyle: React.CSSProperties = {
};
const sourceBadgeBase: React.CSSProperties = {
padding: "0.15rem 0.4rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "var(--font-sm)",
fontWeight: 500,
@@ -487,11 +487,11 @@ const manualBadge: React.CSSProperties = {
};
const cancelBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.4rem",
padding: "0.25rem 0.5rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
cursor: "pointer",

View File

@@ -62,7 +62,7 @@ export function CategoryPicker({
<div
style={{
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
backgroundColor: "var(--ctp-base)",
overflow: "hidden",
}}
@@ -74,7 +74,7 @@ export function CategoryPicker({
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
padding: "0.4rem 0.5rem",
padding: "0.5rem 0.5rem",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
}}
@@ -99,7 +99,7 @@ export function CategoryPicker({
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
cursor: "pointer",
backgroundColor: isActive
? "rgba(203,166,247,0.2)"
@@ -133,7 +133,7 @@ export function CategoryPicker({
disabled={isMultiStage && !selectedDomain}
style={{
width: "100%",
padding: "0.4rem 0.5rem",
padding: "0.5rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
borderBottom: "1px solid var(--ctp-surface1)",

View File

@@ -382,7 +382,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
onClick={handleThumbnailSelect}
style={{
aspectRatio: "4/3",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
border: "1px dashed var(--ctp-surface1)",
display: "flex",
alignItems: "center",
@@ -619,7 +619,7 @@ function SidebarSection({
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--ctp-subtext0)",
marginBottom: "0.4rem",
marginBottom: "0.5rem",
}}
>
{title}
@@ -636,7 +636,7 @@ function MetaRow({ label, value }: { label: string; value: string }) {
display: "flex",
justifyContent: "space-between",
fontSize: "var(--font-table)",
padding: "0.15rem 0",
padding: "0.25rem 0",
}}
>
<span style={{ color: "var(--ctp-subtext0)" }}>{label}</span>
@@ -686,7 +686,7 @@ const actionBtnStyle: React.CSSProperties = {
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-crust)",
cursor: "pointer",
};
@@ -698,17 +698,17 @@ const cancelBtnStyle: React.CSSProperties = {
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.35rem 0.5rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-body)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
boxSizing: "border-box",
};
@@ -723,7 +723,7 @@ const errorStyle: React.CSSProperties = {
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
marginBottom: "0.5rem",
fontSize: "var(--font-body)",
};

View File

@@ -73,7 +73,7 @@ export function DeleteItemPane({
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
fontSize: "var(--font-body)",
width: "100%",
textAlign: "center",
@@ -125,7 +125,7 @@ export function DeleteItemPane({
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
cursor: "pointer",
@@ -141,7 +141,7 @@ export function DeleteItemPane({
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-red)",
color: "var(--ctp-crust)",
cursor: "pointer",
@@ -163,6 +163,6 @@ const headerBtnStyle: React.CSSProperties = {
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
};

View File

@@ -93,7 +93,7 @@ export function EditItemPane({
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-blue)",
color: "var(--ctp-crust)",
cursor: "pointer",
@@ -114,7 +114,7 @@ export function EditItemPane({
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
marginBottom: "0.5rem",
fontSize: "var(--font-body)",
}}
@@ -208,11 +208,11 @@ function FormGroup({
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.35rem 0.5rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-body)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
};
@@ -223,6 +223,6 @@ const headerBtnStyle: React.CSSProperties = {
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
};

View File

@@ -143,8 +143,8 @@ function FileRow({
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
position: "relative",
}}
>
@@ -153,14 +153,14 @@ function FileRow({
style={{
width: 28,
height: 28,
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: color,
opacity: 0.8,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "var(--font-xs)",
fontWeight: 700,
fontWeight: 600,
color: "var(--ctp-crust)",
flexShrink: 0,
}}
@@ -239,7 +239,7 @@ function FileRow({
cursor: "pointer",
fontSize: "var(--font-table)",
color: hovered ? "var(--ctp-red)" : "var(--ctp-overlay0)",
padding: "0 0.2rem",
padding: "0 0.25rem",
flexShrink: 0,
transition: "all 0.15s ease",
}}

View File

@@ -90,7 +90,7 @@ export function ImportItemsPane({
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
marginBottom: "0.5rem",
fontSize: "var(--font-body)",
}}
@@ -164,7 +164,7 @@ export function ImportItemsPane({
style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
gap: "0.5rem",
fontSize: "var(--font-body)",
color: "var(--ctp-subtext1)",
marginBottom: "0.75rem",
@@ -187,11 +187,11 @@ export function ImportItemsPane({
onClick={() => void doImport(true)}
disabled={!file || importing}
style={{
padding: "0.4rem 0.75rem",
padding: "0.5rem 0.75rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-yellow)",
color: "var(--ctp-crust)",
cursor: "pointer",
@@ -205,11 +205,11 @@ export function ImportItemsPane({
onClick={() => void doImport(false)}
disabled={importing || (result?.error_count ?? 0) > 0}
style={{
padding: "0.4rem 0.75rem",
padding: "0.5rem 0.75rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-green)",
color: "var(--ctp-crust)",
cursor: "pointer",
@@ -227,7 +227,7 @@ export function ImportItemsPane({
style={{
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
fontSize: "var(--font-table)",
}}
>
@@ -262,7 +262,7 @@ export function ImportItemsPane({
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
padding: "0.15rem 0",
padding: "0.25rem 0",
}}
>
Row {err.row}
@@ -296,6 +296,6 @@ const headerBtnStyle: React.CSSProperties = {
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
};

View File

@@ -103,7 +103,7 @@ export function ItemDetail({
</span>
<span
style={{
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "var(--font-sm)",
fontWeight: 500,
@@ -157,7 +157,7 @@ export function ItemDetail({
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: "0.4rem 0.75rem",
padding: "0.5rem 0.75rem",
fontSize: "var(--font-table)",
border: "none",
borderBottom:
@@ -205,5 +205,5 @@ const headerBtnStyle: React.CSSProperties = {
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "var(--font-table)",
padding: "0.25rem 0.4rem",
padding: "0.25rem 0.5rem",
};

View File

@@ -268,7 +268,7 @@ export function ItemTable({
<td key={col.key} style={tdStyle}>
<span
style={{
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 500,
@@ -398,6 +398,6 @@ const actionBtnStyle: React.CSSProperties = {
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.15rem 0.4rem",
borderRadius: "0.375rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
};

View File

@@ -41,7 +41,7 @@ export function ItemsToolbar({
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
cursor: "pointer",
backgroundColor:
filters.searchScope === scope
@@ -81,7 +81,7 @@ export function ItemsToolbar({
padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
color: "var(--ctp-text)",
fontSize: "var(--d-input-font)",
}}
@@ -144,7 +144,7 @@ export function ItemsToolbar({
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
gap: "0.25rem",
}}
title="Export CSV"
>
@@ -159,7 +159,7 @@ export function ItemsToolbar({
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
gap: "0.25rem",
}}
title="Import CSV"
>
@@ -177,7 +177,7 @@ export function ItemsToolbar({
color: "var(--ctp-crust)",
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
gap: "0.25rem",
}}
>
<Plus size={14} /> New
@@ -191,7 +191,7 @@ const selectStyle: React.CSSProperties = {
padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
color: "var(--ctp-text)",
fontSize: "var(--d-input-font)",
};
@@ -200,7 +200,7 @@ const toolBtnStyle: React.CSSProperties = {
padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: "var(--ctp-surface1)",
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
fontSize: "0.75rem",
fontWeight: 500,

View File

@@ -134,7 +134,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
marginTop: "0.75rem",
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
fontSize: "var(--font-body)",
}}
>
@@ -177,7 +177,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
backgroundColor: "rgba(203,166,247,0.15)",
color: "var(--ctp-mauve)",
@@ -208,11 +208,11 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
value={addProject}
onChange={(e) => setAddProject(e.target.value)}
style={{
padding: "0.15rem 0.25rem",
padding: "0.25rem 0.25rem",
fontSize: "0.75rem",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
}}
>
@@ -229,12 +229,12 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
<button
onClick={() => void handleAddProject()}
style={{
padding: "0.15rem 0.4rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-sm)",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
cursor: "pointer",
}}
>
@@ -253,7 +253,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
marginTop: "0.75rem",
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
}}
>
<div
@@ -298,7 +298,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
cursor: "pointer",
}}
>

View File

@@ -125,11 +125,11 @@ export function PropertiesTab({
};
const inputStyle: React.CSSProperties = {
padding: "0.25rem 0.4rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
};
@@ -165,7 +165,7 @@ export function PropertiesTab({
padding: "0.25rem 0.75rem",
fontSize: "var(--font-table)",
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
cursor: "pointer",
@@ -250,7 +250,7 @@ export function PropertiesTab({
marginTop: "0.25rem",
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
gap: "0.25rem",
}}
>
<Plus size={14} /> Add Property
@@ -274,7 +274,7 @@ export function PropertiesTab({
fontSize: "var(--font-table)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
color: "var(--ctp-text)",
resize: "vertical",
}}
@@ -300,7 +300,7 @@ const tabBtn: React.CSSProperties = {
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-subtext1)",
cursor: "pointer",

View File

@@ -97,11 +97,11 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
);
const selectStyle: React.CSSProperties = {
padding: "0.25rem 0.4rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
};
@@ -147,7 +147,7 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
cursor: "pointer",
@@ -164,7 +164,7 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
style={{
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
fontSize: "var(--font-table)",
marginBottom: "0.75rem",
fontFamily: "'JetBrains Mono', monospace",
@@ -250,10 +250,10 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
)
}
style={{
padding: "0.15rem 0.25rem",
padding: "0.25rem 0.25rem",
fontSize: "0.75rem",
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
backgroundColor: "transparent",
color: statusColors[rev.status] ?? "var(--ctp-text)",
cursor: "pointer",

View File

@@ -116,7 +116,7 @@ const titleStyle: React.CSSProperties = {
color: "var(--ctp-mauve)",
textAlign: "center",
fontSize: "2rem",
fontWeight: 700,
fontWeight: 600,
marginBottom: "0.25rem",
};
@@ -164,7 +164,7 @@ const btnPrimaryStyle: React.CSSProperties = {
display: "block",
width: "100%",
padding: "0.75rem 1.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",
@@ -189,7 +189,7 @@ const btnOidcStyle: React.CSSProperties = {
display: "block",
width: "100%",
padding: "0.75rem 1.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",

View File

@@ -216,7 +216,7 @@ export function ProjectsPage() {
...btnPrimaryStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
gap: "0.25rem",
}}
>
<Plus size={14} /> New Project
@@ -465,7 +465,7 @@ export function ProjectsPage() {
// Styles
const btnPrimaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
@@ -476,7 +476,7 @@ const btnPrimaryStyle: React.CSSProperties = {
const btnSecondaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
@@ -487,7 +487,7 @@ const btnSecondaryStyle: React.CSSProperties = {
const btnDangerStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-red)",
color: "var(--ctp-crust)",
@@ -498,7 +498,7 @@ const btnDangerStyle: React.CSSProperties = {
const btnSmallStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
@@ -530,7 +530,7 @@ const formCloseStyle: React.CSSProperties = {
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
borderRadius: "0.375rem",
borderRadius: "0.25rem",
};
const errorBannerStyle: React.CSSProperties = {
@@ -538,7 +538,7 @@ const errorBannerStyle: React.CSSProperties = {
background: "rgba(243, 139, 168, 0.1)",
border: "1px solid rgba(243, 139, 168, 0.2)",
padding: "0.5rem 0.75rem",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
marginBottom: "0.75rem",
fontSize: "var(--font-body)",
};
@@ -549,7 +549,7 @@ const fieldStyle: React.CSSProperties = {
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "0.35rem",
marginBottom: "0.25rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "var(--font-body)",
@@ -560,7 +560,7 @@ const inputStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
color: "var(--ctp-text)",
fontSize: "var(--font-body)",
boxSizing: "border-box",
@@ -587,7 +587,7 @@ const thStyle: React.CSSProperties = {
};
const tdStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem",
padding: "0.25rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "var(--font-body)",
};

View File

@@ -657,7 +657,7 @@ function SegmentBlock({
marginTop: "0.5rem",
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
gap: "0.25rem",
}}
>
<Plus size={14} /> Add Value
@@ -692,7 +692,7 @@ const segmentStyle: React.CSSProperties = {
const typeBadgeStyle: React.CSSProperties = {
display: "inline-block",
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
fontSize: "0.75rem",
fontWeight: 600,
@@ -707,7 +707,7 @@ const emptyStyle: React.CSSProperties = {
};
const thStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
padding: "0.5rem 0.75rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-overlay1)",
@@ -725,7 +725,7 @@ const tdStyle: React.CSSProperties = {
const btnTinyStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
@@ -736,7 +736,7 @@ const btnTinyStyle: React.CSSProperties = {
const btnTinyPrimaryStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",

View File

@@ -114,7 +114,7 @@ export function SettingsPage() {
<span
style={{
display: "inline-block",
padding: "0.15rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "var(--font-table)",
fontWeight: 600,
@@ -386,7 +386,7 @@ const createFormStyle: React.CSSProperties = {
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "0.35rem",
marginBottom: "0.25rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "var(--font-body)",
@@ -397,7 +397,7 @@ const inputStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
borderRadius: "0.5rem",
color: "var(--ctp-text)",
fontSize: "var(--font-body)",
boxSizing: "border-box",
@@ -405,7 +405,7 @@ const inputStyle: React.CSSProperties = {
const btnPrimaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
@@ -416,10 +416,10 @@ const btnPrimaryStyle: React.CSSProperties = {
};
const btnCopyStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
padding: "0.5rem 0.75rem",
background: "var(--ctp-surface1)",
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
cursor: "pointer",
fontSize: "0.75rem",
@@ -427,10 +427,10 @@ const btnCopyStyle: React.CSSProperties = {
};
const btnDismissStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
padding: "0.5rem 0.75rem",
background: "none",
border: "none",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
color: "var(--ctp-subtext0)",
cursor: "pointer",
fontSize: "0.75rem",
@@ -442,7 +442,7 @@ const btnDangerStyle: React.CSSProperties = {
color: "var(--ctp-red)",
border: "none",
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
@@ -453,7 +453,7 @@ const btnRevokeConfirmStyle: React.CSSProperties = {
color: "var(--ctp-crust)",
border: "none",
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
@@ -461,7 +461,7 @@ const btnRevokeConfirmStyle: React.CSSProperties = {
const btnTinyStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
@@ -488,7 +488,7 @@ const thStyle: React.CSSProperties = {
};
const tdStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "var(--font-body)",
};

View File

@@ -1,6 +1,8 @@
/* Focus and hover states for form inputs */
.silo-input {
transition: border-color 0.15s ease, box-shadow 0.15s ease;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.silo-input:hover {
@@ -9,6 +11,6 @@
.silo-input:focus {
border-color: var(--ctp-mauve);
box-shadow: 0 0 0 0.2rem rgba(203, 166, 247, 0.25);
box-shadow: 0 0 0 0.25rem rgba(203, 166, 247, 0.25);
outline: none;
}

View File

@@ -44,13 +44,13 @@
--d-header-px: 2rem;
--d-header-logo: 1.25rem;
--d-nav-gap: 1rem;
--d-nav-py: 0.35rem;
--d-nav-py: 0.25rem;
--d-nav-px: 0.75rem;
--d-nav-radius: 0.4rem;
--d-user-gap: 0.6rem;
--d-nav-radius: 0.5rem;
--d-user-gap: 0.5rem;
--d-user-font: var(--font-body);
--d-th-py: 0.35rem;
--d-th-py: 0.25rem;
--d-th-px: 0.75rem;
--d-th-font: var(--font-table);
--d-td-py: 0.25rem;
@@ -59,9 +59,9 @@
--d-toolbar-gap: 0.5rem;
--d-toolbar-py: 0.5rem;
--d-toolbar-mb: 0.35rem;
--d-input-py: 0.35rem;
--d-input-px: 0.6rem;
--d-toolbar-mb: 0.25rem;
--d-input-py: 0.25rem;
--d-input-px: 0.5rem;
--d-input-font: var(--font-body);
--d-footer-h: 28px;
@@ -71,28 +71,28 @@
/* ── Density: compact ── */
[data-density="compact"] {
--d-header-py: 0.35rem;
--d-header-py: 0.25rem;
--d-header-px: 1.25rem;
--d-header-logo: 1.1rem;
--d-nav-gap: 0.5rem;
--d-nav-py: 0.2rem;
--d-nav-py: 0.25rem;
--d-nav-px: 0.5rem;
--d-nav-radius: 0.3rem;
--d-user-gap: 0.35rem;
--d-nav-radius: 0.25rem;
--d-user-gap: 0.25rem;
--d-user-font: var(--font-table);
--d-th-py: 0.2rem;
--d-th-py: 0.25rem;
--d-th-px: 0.5rem;
--d-th-font: var(--font-sm);
--d-td-py: 0.125rem;
--d-td-py: 0.25rem;
--d-td-px: 0.5rem;
--d-td-font: var(--font-table);
--d-toolbar-gap: 0.35rem;
--d-toolbar-gap: 0.25rem;
--d-toolbar-py: 0.25rem;
--d-toolbar-mb: 0.15rem;
--d-input-py: 0.2rem;
--d-input-px: 0.4rem;
--d-toolbar-mb: 0.25rem;
--d-input-py: 0.25rem;
--d-input-px: 0.5rem;
--d-input-font: var(--font-table);
--d-footer-h: 24px;