Compare commits
4 Commits
production
...
feat-modul
| Author | SHA1 | Date | |
|---|---|---|---|
| a6267ba3d5 | |||
|
|
c98eed0b13 | ||
|
|
ca71153c15 | ||
|
|
6e49fade8b |
@@ -47,7 +47,6 @@ type Server struct {
|
|||||||
serverState *ServerState
|
serverState *ServerState
|
||||||
dag *db.DAGRepository
|
dag *db.DAGRepository
|
||||||
jobs *db.JobRepository
|
jobs *db.JobRepository
|
||||||
locations *db.LocationRepository
|
|
||||||
jobDefs map[string]*jobdef.Definition
|
jobDefs map[string]*jobdef.Definition
|
||||||
jobDefsDir string
|
jobDefsDir string
|
||||||
modules *modules.Registry
|
modules *modules.Registry
|
||||||
@@ -80,7 +79,6 @@ func NewServer(
|
|||||||
dag := db.NewDAGRepository(database)
|
dag := db.NewDAGRepository(database)
|
||||||
jobs := db.NewJobRepository(database)
|
jobs := db.NewJobRepository(database)
|
||||||
settings := db.NewSettingsRepository(database)
|
settings := db.NewSettingsRepository(database)
|
||||||
locations := db.NewLocationRepository(database)
|
|
||||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||||
|
|
||||||
@@ -103,7 +101,6 @@ func NewServer(
|
|||||||
serverState: state,
|
serverState: state,
|
||||||
dag: dag,
|
dag: dag,
|
||||||
jobs: jobs,
|
jobs: jobs,
|
||||||
locations: locations,
|
|
||||||
jobDefs: jobDefs,
|
jobDefs: jobDefs,
|
||||||
jobDefsDir: jobDefsDir,
|
jobDefsDir: jobDefsDir,
|
||||||
modules: registry,
|
modules: registry,
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/kindredsystems/silo/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LocationResponse is the API representation of a location.
|
|
||||||
type LocationResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ParentID *string `json:"parent_id,omitempty"`
|
|
||||||
LocationType string `json:"location_type"`
|
|
||||||
Depth int `json:"depth"`
|
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateLocationRequest represents a request to create a location.
|
|
||||||
type CreateLocationRequest struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
LocationType string `json:"location_type"`
|
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateLocationRequest represents a request to update a location.
|
|
||||||
type UpdateLocationRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
LocationType string `json:"location_type"`
|
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationToResponse(loc *db.Location) LocationResponse {
|
|
||||||
return LocationResponse{
|
|
||||||
ID: loc.ID,
|
|
||||||
Path: loc.Path,
|
|
||||||
Name: loc.Name,
|
|
||||||
ParentID: loc.ParentID,
|
|
||||||
LocationType: loc.LocationType,
|
|
||||||
Depth: loc.Depth,
|
|
||||||
Metadata: loc.Metadata,
|
|
||||||
CreatedAt: loc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleListLocations lists all locations. If ?tree={path} is set, returns that
|
|
||||||
// subtree. If ?root=true, returns only root-level locations (depth 0).
|
|
||||||
func (s *Server) HandleListLocations(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
treePath := r.URL.Query().Get("tree")
|
|
||||||
if treePath != "" {
|
|
||||||
locs, err := s.locations.GetTree(ctx, treePath)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error().Err(err).Str("tree", treePath).Msg("failed to get location tree")
|
|
||||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get location tree")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, locationsToResponse(locs))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
locs, err := s.locations.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error().Err(err).Msg("failed to list locations")
|
|
||||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list locations")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, locationsToResponse(locs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleCreateLocation creates a new location.
|
|
||||||
func (s *Server) HandleCreateLocation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
var req CreateLocationRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Path == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Path is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Name == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.LocationType == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location type is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize: trim slashes
|
|
||||||
req.Path = strings.Trim(req.Path, "/")
|
|
||||||
|
|
||||||
loc := &db.Location{
|
|
||||||
Path: req.Path,
|
|
||||||
Name: req.Name,
|
|
||||||
LocationType: req.LocationType,
|
|
||||||
Metadata: req.Metadata,
|
|
||||||
}
|
|
||||||
if loc.Metadata == nil {
|
|
||||||
loc.Metadata = map[string]any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.locations.Create(ctx, loc); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "parent location") || strings.Contains(err.Error(), "does not exist") {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_parent", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
|
|
||||||
writeError(w, http.StatusConflict, "already_exists", "Location path already exists")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.logger.Error().Err(err).Str("path", req.Path).Msg("failed to create location")
|
|
||||||
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, locationToResponse(loc))
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGetLocation retrieves a location by path. The path is the rest of the
|
|
||||||
// URL after /api/locations/, which chi captures as a wildcard.
|
|
||||||
func (s *Server) HandleGetLocation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
path := strings.Trim(chi.URLParam(r, "*"), "/")
|
|
||||||
|
|
||||||
if path == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, err := s.locations.GetByPath(ctx, path)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error().Err(err).Str("path", path).Msg("failed to get location")
|
|
||||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get location")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if loc == nil {
|
|
||||||
writeError(w, http.StatusNotFound, "not_found", "Location not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, locationToResponse(loc))
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleUpdateLocation updates a location by path.
|
|
||||||
func (s *Server) HandleUpdateLocation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
path := strings.Trim(chi.URLParam(r, "*"), "/")
|
|
||||||
|
|
||||||
if path == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateLocationRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.LocationType == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location type is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := req.Metadata
|
|
||||||
if meta == nil {
|
|
||||||
meta = map[string]any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.locations.Update(ctx, path, req.Name, req.LocationType, meta); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "not found") {
|
|
||||||
writeError(w, http.StatusNotFound, "not_found", "Location not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.logger.Error().Err(err).Str("path", path).Msg("failed to update location")
|
|
||||||
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, _ := s.locations.GetByPath(ctx, path)
|
|
||||||
writeJSON(w, http.StatusOK, locationToResponse(loc))
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleDeleteLocation deletes a location by path. Rejects if inventory exists.
|
|
||||||
func (s *Server) HandleDeleteLocation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
path := strings.Trim(chi.URLParam(r, "*"), "/")
|
|
||||||
|
|
||||||
if path == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.locations.Delete(ctx, path); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "inventory record") {
|
|
||||||
writeError(w, http.StatusConflict, "has_inventory", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.Contains(err.Error(), "not found") {
|
|
||||||
writeError(w, http.StatusNotFound, "not_found", "Location not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.logger.Error().Err(err).Str("path", path).Msg("failed to delete location")
|
|
||||||
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationsToResponse(locs []*db.Location) []LocationResponse {
|
|
||||||
result := make([]LocationResponse, len(locs))
|
|
||||||
for i, l := range locs {
|
|
||||||
result[i] = locationToResponse(l)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newLocationRouter(s *Server) http.Handler {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/api/locations", s.HandleListLocations)
|
|
||||||
r.Post("/api/locations", s.HandleCreateLocation)
|
|
||||||
r.Get("/api/locations/*", s.HandleGetLocation)
|
|
||||||
r.Put("/api/locations/*", s.HandleUpdateLocation)
|
|
||||||
r.Delete("/api/locations/*", s.HandleDeleteLocation)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleListLocationsEmpty(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/locations", 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 locs []LocationResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &locs); err != nil {
|
|
||||||
t.Fatalf("decoding response: %v", err)
|
|
||||||
}
|
|
||||||
if len(locs) != 0 {
|
|
||||||
t.Fatalf("expected 0 locations, got %d", len(locs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCreateAndGetLocation(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
// Create root location
|
|
||||||
body := `{"path": "lab", "name": "Lab", "location_type": "building"}`
|
|
||||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var created LocationResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
|
||||||
t.Fatalf("decoding create response: %v", err)
|
|
||||||
}
|
|
||||||
if created.Path != "lab" {
|
|
||||||
t.Errorf("path: got %q, want %q", created.Path, "lab")
|
|
||||||
}
|
|
||||||
if created.Name != "Lab" {
|
|
||||||
t.Errorf("name: got %q, want %q", created.Name, "Lab")
|
|
||||||
}
|
|
||||||
if created.Depth != 0 {
|
|
||||||
t.Errorf("depth: got %d, want 0", created.Depth)
|
|
||||||
}
|
|
||||||
if created.ID == "" {
|
|
||||||
t.Error("expected ID to be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get by path
|
|
||||||
req = httptest.NewRequest("GET", "/api/locations/lab", nil)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("get status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var got LocationResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("decoding get response: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != created.ID {
|
|
||||||
t.Errorf("ID mismatch: got %q, want %q", got.ID, created.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCreateNestedLocation(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
// Create root
|
|
||||||
body := `{"path": "warehouse", "name": "Warehouse", "location_type": "building"}`
|
|
||||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create root: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create child
|
|
||||||
body = `{"path": "warehouse/shelf-a", "name": "Shelf A", "location_type": "shelf"}`
|
|
||||||
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create child: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var child LocationResponse
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &child)
|
|
||||||
if child.Depth != 1 {
|
|
||||||
t.Errorf("child depth: got %d, want 1", child.Depth)
|
|
||||||
}
|
|
||||||
if child.ParentID == nil {
|
|
||||||
t.Error("expected parent_id to be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create grandchild
|
|
||||||
body = `{"path": "warehouse/shelf-a/bin-3", "name": "Bin 3", "location_type": "bin"}`
|
|
||||||
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create grandchild: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var gc LocationResponse
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &gc)
|
|
||||||
if gc.Depth != 2 {
|
|
||||||
t.Errorf("grandchild depth: got %d, want 2", gc.Depth)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get nested path
|
|
||||||
req = httptest.NewRequest("GET", "/api/locations/warehouse/shelf-a/bin-3", nil)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("get nested: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCreateLocationMissingParent(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
body := `{"path": "nonexistent/child", "name": "Child", "location_type": "shelf"}`
|
|
||||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleUpdateLocation(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
// Create
|
|
||||||
body := `{"path": "office", "name": "Office", "location_type": "room"}`
|
|
||||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update
|
|
||||||
body = `{"name": "Main Office", "location_type": "building", "metadata": {"floor": 2}}`
|
|
||||||
req = httptest.NewRequest("PUT", "/api/locations/office", strings.NewReader(body))
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("update: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var updated LocationResponse
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &updated)
|
|
||||||
if updated.Name != "Main Office" {
|
|
||||||
t.Errorf("name: got %q, want %q", updated.Name, "Main Office")
|
|
||||||
}
|
|
||||||
if updated.LocationType != "building" {
|
|
||||||
t.Errorf("type: got %q, want %q", updated.LocationType, "building")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleDeleteLocation(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
// Create
|
|
||||||
body := `{"path": "temp", "name": "Temp", "location_type": "area"}`
|
|
||||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
req = httptest.NewRequest("DELETE", "/api/locations/temp", nil)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("delete: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify gone
|
|
||||||
req = httptest.NewRequest("GET", "/api/locations/temp", nil)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("get after delete: got %d, want %d", w.Code, http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleDeleteLocationNotFound(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("DELETE", "/api/locations/doesnotexist", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("delete missing: got %d, want %d; body: %s", w.Code, http.StatusNotFound, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleListLocationsTree(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
// Create hierarchy
|
|
||||||
for _, loc := range []string{
|
|
||||||
`{"path": "site", "name": "Site", "location_type": "site"}`,
|
|
||||||
`{"path": "site/bldg", "name": "Building", "location_type": "building"}`,
|
|
||||||
`{"path": "site/bldg/room1", "name": "Room 1", "location_type": "room"}`,
|
|
||||||
`{"path": "other", "name": "Other", "location_type": "site"}`,
|
|
||||||
} {
|
|
||||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(loc))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List tree under "site"
|
|
||||||
req := httptest.NewRequest("GET", "/api/locations?tree=site", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("tree: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var locs []LocationResponse
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &locs)
|
|
||||||
if len(locs) != 3 {
|
|
||||||
t.Fatalf("tree count: got %d, want 3 (site + bldg + room1)", len(locs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full list should have 4
|
|
||||||
req = httptest.NewRequest("GET", "/api/locations", nil)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &locs)
|
|
||||||
if len(locs) != 4 {
|
|
||||||
t.Fatalf("full list: got %d, want 4", len(locs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCreateLocationDuplicate(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
body := `{"path": "dup", "name": "Dup", "location_type": "area"}`
|
|
||||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("first create: got %d; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate
|
|
||||||
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusConflict {
|
|
||||||
t.Fatalf("duplicate: got %d, want %d; body: %s", w.Code, http.StatusConflict, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCreateLocationValidation(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
router := newLocationRouter(s)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
}{
|
|
||||||
{"missing path", `{"name": "X", "location_type": "area"}`},
|
|
||||||
{"missing name", `{"path": "x", "location_type": "area"}`},
|
|
||||||
{"missing type", `{"path": "x", "name": "X"}`},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(tc.body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("got %d, want 400; body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -117,26 +117,6 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Locations (read: viewer, write: editor)
|
|
||||||
r.Route("/locations", func(r chi.Router) {
|
|
||||||
r.Get("/", server.HandleListLocations)
|
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(server.RequireWritable)
|
|
||||||
r.Use(server.RequireRole(auth.RoleEditor))
|
|
||||||
r.Post("/", server.HandleCreateLocation)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wildcard routes for path-based lookup (e.g., /api/locations/lab/shelf-a/bin-3)
|
|
||||||
r.Get("/*", server.HandleGetLocation)
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(server.RequireWritable)
|
|
||||||
r.Use(server.RequireRole(auth.RoleEditor))
|
|
||||||
r.Put("/*", server.HandleUpdateLocation)
|
|
||||||
r.Delete("/*", server.HandleDeleteLocation)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Items (read: viewer, write: editor)
|
// Items (read: viewer, write: editor)
|
||||||
r.Route("/items", func(r chi.Router) {
|
r.Route("/items", func(r chi.Router) {
|
||||||
r.Get("/", server.HandleListItems)
|
r.Get("/", server.HandleListItems)
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Location represents a location in the hierarchy.
|
|
||||||
type Location struct {
|
|
||||||
ID string
|
|
||||||
Path string
|
|
||||||
Name string
|
|
||||||
ParentID *string
|
|
||||||
LocationType string
|
|
||||||
Depth int
|
|
||||||
Metadata map[string]any
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocationRepository provides location database operations.
|
|
||||||
type LocationRepository struct {
|
|
||||||
db *DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLocationRepository creates a new location repository.
|
|
||||||
func NewLocationRepository(db *DB) *LocationRepository {
|
|
||||||
return &LocationRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all locations ordered by path.
|
|
||||||
func (r *LocationRepository) List(ctx context.Context) ([]*Location, error) {
|
|
||||||
rows, err := r.db.pool.Query(ctx, `
|
|
||||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
||||||
FROM locations
|
|
||||||
ORDER BY path
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanLocations(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByPath returns a location by its path.
|
|
||||||
func (r *LocationRepository) GetByPath(ctx context.Context, path string) (*Location, error) {
|
|
||||||
loc := &Location{}
|
|
||||||
var meta []byte
|
|
||||||
err := r.db.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
||||||
FROM locations
|
|
||||||
WHERE path = $1
|
|
||||||
`, path).Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if meta != nil {
|
|
||||||
json.Unmarshal(meta, &loc.Metadata)
|
|
||||||
}
|
|
||||||
return loc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByID returns a location by its ID.
|
|
||||||
func (r *LocationRepository) GetByID(ctx context.Context, id string) (*Location, error) {
|
|
||||||
loc := &Location{}
|
|
||||||
var meta []byte
|
|
||||||
err := r.db.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
||||||
FROM locations
|
|
||||||
WHERE id = $1
|
|
||||||
`, id).Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if meta != nil {
|
|
||||||
json.Unmarshal(meta, &loc.Metadata)
|
|
||||||
}
|
|
||||||
return loc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetChildren returns direct children of a location.
|
|
||||||
func (r *LocationRepository) GetChildren(ctx context.Context, parentID string) ([]*Location, error) {
|
|
||||||
rows, err := r.db.pool.Query(ctx, `
|
|
||||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
||||||
FROM locations
|
|
||||||
WHERE parent_id = $1
|
|
||||||
ORDER BY path
|
|
||||||
`, parentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanLocations(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTree returns a location and all its descendants (by path prefix).
|
|
||||||
func (r *LocationRepository) GetTree(ctx context.Context, rootPath string) ([]*Location, error) {
|
|
||||||
rows, err := r.db.pool.Query(ctx, `
|
|
||||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
||||||
FROM locations
|
|
||||||
WHERE path = $1 OR path LIKE $2
|
|
||||||
ORDER BY path
|
|
||||||
`, rootPath, rootPath+"/%")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanLocations(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new location. ParentID and Depth are resolved from the path.
|
|
||||||
func (r *LocationRepository) Create(ctx context.Context, loc *Location) error {
|
|
||||||
// Auto-calculate depth from path segments
|
|
||||||
loc.Depth = strings.Count(loc.Path, "/")
|
|
||||||
|
|
||||||
// Resolve parent_id from path if not explicitly set
|
|
||||||
if loc.ParentID == nil && loc.Depth > 0 {
|
|
||||||
parentPath := loc.Path[:strings.LastIndex(loc.Path, "/")]
|
|
||||||
parent, err := r.GetByPath(ctx, parentPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("looking up parent %q: %w", parentPath, err)
|
|
||||||
}
|
|
||||||
if parent == nil {
|
|
||||||
return fmt.Errorf("parent location %q does not exist", parentPath)
|
|
||||||
}
|
|
||||||
loc.ParentID = &parent.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
meta, err := json.Marshal(loc.Metadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshaling metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.db.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO locations (path, name, parent_id, location_type, depth, metadata)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id, created_at
|
|
||||||
`, loc.Path, loc.Name, loc.ParentID, loc.LocationType, loc.Depth, meta).Scan(&loc.ID, &loc.CreatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates a location's name, type, and metadata.
|
|
||||||
func (r *LocationRepository) Update(ctx context.Context, path string, name, locationType string, metadata map[string]any) error {
|
|
||||||
meta, err := json.Marshal(metadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshaling metadata: %w", err)
|
|
||||||
}
|
|
||||||
tag, err := r.db.pool.Exec(ctx, `
|
|
||||||
UPDATE locations
|
|
||||||
SET name = $2, location_type = $3, metadata = $4
|
|
||||||
WHERE path = $1
|
|
||||||
`, path, name, locationType, meta)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return fmt.Errorf("location %q not found", path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a location. Returns an error if inventory rows reference it.
|
|
||||||
func (r *LocationRepository) Delete(ctx context.Context, path string) error {
|
|
||||||
// Check for inventory references
|
|
||||||
var count int
|
|
||||||
err := r.db.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM inventory
|
|
||||||
WHERE location_id = (SELECT id FROM locations WHERE path = $1)
|
|
||||||
`, path).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return fmt.Errorf("cannot delete location %q: %d inventory record(s) exist", path, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete children first (cascade by path prefix), deepest first
|
|
||||||
_, err = r.db.pool.Exec(ctx, `
|
|
||||||
DELETE FROM locations
|
|
||||||
WHERE path LIKE $1
|
|
||||||
`, path+"/%")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tag, err := r.db.pool.Exec(ctx, `DELETE FROM locations WHERE path = $1`, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return fmt.Errorf("location %q not found", path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasInventory checks if a location (or descendants) have inventory records.
|
|
||||||
func (r *LocationRepository) HasInventory(ctx context.Context, path string) (bool, error) {
|
|
||||||
var count int
|
|
||||||
err := r.db.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM inventory i
|
|
||||||
JOIN locations l ON l.id = i.location_id
|
|
||||||
WHERE l.path = $1 OR l.path LIKE $2
|
|
||||||
`, path, path+"/%").Scan(&count)
|
|
||||||
return count > 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanLocations(rows pgx.Rows) ([]*Location, error) {
|
|
||||||
var locs []*Location
|
|
||||||
for rows.Next() {
|
|
||||||
loc := &Location{}
|
|
||||||
var meta []byte
|
|
||||||
if err := rows.Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if meta != nil {
|
|
||||||
json.Unmarshal(meta, &loc.Metadata)
|
|
||||||
}
|
|
||||||
locs = append(locs, loc)
|
|
||||||
}
|
|
||||||
return locs, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -84,7 +84,7 @@ func TruncateAll(t *testing.T, pool *pgxpool.Pool) {
|
|||||||
dag_cross_edges, dag_edges, dag_nodes,
|
dag_cross_edges, dag_edges, dag_nodes,
|
||||||
audit_log, sync_log, api_tokens, sessions, item_files,
|
audit_log, sync_log, api_tokens, sessions, item_files,
|
||||||
item_projects, relationships, revisions, inventory, items,
|
item_projects, relationships, revisions, inventory, items,
|
||||||
locations, projects, sequences_by_name, users, property_migrations
|
projects, sequences_by_name, users, property_migrations
|
||||||
CASCADE
|
CASCADE
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -352,35 +352,6 @@ export interface UpdateSchemaValueRequest {
|
|||||||
description: string;
|
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
|
// Revision comparison
|
||||||
export interface RevisionComparison {
|
export interface RevisionComparison {
|
||||||
from: number;
|
from: number;
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,655 +0,0 @@
|
|||||||
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,7 +2,6 @@ import { useEffect, useState, type FormEvent } from "react";
|
|||||||
import { get, post, del } from "../api/client";
|
import { get, post, del } from "../api/client";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import type { ApiToken, ApiTokenCreated } from "../api/types";
|
import type { ApiToken, ApiTokenCreated } from "../api/types";
|
||||||
import { AdminModules } from "../components/settings/AdminModules";
|
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -312,9 +311,6 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin: Module Configuration */}
|
|
||||||
{user?.role === "admin" && <AdminModules />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user