From 80553c64500df66a13f6d8c9b9835fdfce679c84 Mon Sep 17 00:00:00 2001 From: Noste <83548733+Noooste@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:21:49 +0100 Subject: [PATCH] feat: enhance authentication and upload features; add JWT support and upload progress component Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com> --- Makefile | 137 +++++ backend/internal/auth/auth.go | 2 +- backend/internal/auth/jwt.go | 91 +++- backend/internal/config/config.go | 31 +- backend/internal/handlers/buckets.go | 23 +- backend/internal/handlers/objects.go | 36 +- backend/internal/middleware/auth.go | 3 +- backend/internal/models/responses.go | 18 +- backend/internal/routes/routes.go | 73 ++- backend/internal/services/s3.go | 113 +++-- backend/main.go | 31 +- config.yaml.example | 13 + docker-compose.yml | 8 + frontend/src/App.css | 42 -- frontend/src/App.tsx | 2 + .../src/components/auth/BasicLoginForm.tsx | 24 +- .../src/components/auth/OIDCLoginView.tsx | 8 +- .../src/components/buckets/BucketListView.tsx | 3 +- .../components/buckets/ObjectBrowserView.tsx | 11 +- .../components/buckets/ObjectDetailsView.tsx | 271 ++++++++++ .../src/components/buckets/ObjectsTable.tsx | 32 +- .../src/components/buckets/UploadProgress.tsx | 115 +++++ .../components/charts/BucketUsageChart.tsx | 2 +- .../components/charts/ClusterHealthChart.tsx | 66 --- .../components/charts/RequestMetricsChart.tsx | 60 --- frontend/src/hooks/index.ts | 3 +- frontend/src/hooks/useBucketObjects.ts | 116 ++++- frontend/src/hooks/useBuckets.ts | 77 --- frontend/src/lib/api.ts | 34 +- frontend/src/lib/file-utils.ts | 15 + frontend/src/lib/utils.ts | 12 - frontend/src/pages/AccessControl.tsx | 324 +++++++++--- frontend/src/pages/Buckets.tsx | 41 +- frontend/src/pages/Cluster.tsx | 2 +- frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/Login.tsx | 4 - frontend/src/types/index.ts | 3 +- frontend/tailwind.config.js | 9 + helm/garage-ui/templates/configmap.yaml | 2 + helm/garage-ui/templates/deployment.yaml | 12 + helm/garage-ui/templates/secret.yaml | 12 + helm/garage-ui/values.schema.json | 45 ++ helm/garage-ui/values.yaml | 479 ++---------------- 43 files changed, 1464 insertions(+), 943 deletions(-) create mode 100644 Makefile delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/components/buckets/ObjectDetailsView.tsx create mode 100644 frontend/src/components/buckets/UploadProgress.tsx delete mode 100644 frontend/src/components/charts/ClusterHealthChart.tsx delete mode 100644 frontend/src/components/charts/RequestMetricsChart.tsx delete mode 100644 frontend/src/hooks/useBuckets.ts diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..283c7d1 --- /dev/null +++ b/Makefile @@ -0,0 +1,137 @@ +.PHONY: help build up down restart logs clean ps health dev-build dev-up dev-down dev-restart dev-logs prod-up prod-down prod-restart prod-logs push stop + +# Variables +DOCKER_COMPOSE = docker-compose +DOCKER_COMPOSE_DEV = docker-compose -f docker-compose.dev.yml +DOCKER_COMPOSE_PROD = docker-compose -f docker-compose.yml +IMAGE_NAME = noooste/garage-ui +IMAGE_TAG = latest + +## help: Show this help message +help: + @echo 'Usage:' + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + +## build: Build the Docker image locally +build: + docker build -t $(IMAGE_NAME):$(IMAGE_TAG) . + +## build-no-cache: Build the Docker image without cache +build-no-cache: + docker build --no-cache -t $(IMAGE_NAME):$(IMAGE_TAG) . + +## push: Push the Docker image to registry +push: build + docker push $(IMAGE_NAME):$(IMAGE_TAG) + +## dev-build: Build and start development environment +dev-build: + $(DOCKER_COMPOSE_DEV) build + +## dev-up: Start development environment +dev-up: + $(DOCKER_COMPOSE_DEV) up -d + +## dev-down: Stop development environment +dev-down: + $(DOCKER_COMPOSE_DEV) down + +## dev-restart: Restart development environment +dev-restart: dev-down dev-up + +## dev-logs: Show logs for development environment +dev-logs: + $(DOCKER_COMPOSE_DEV) logs -f + +## dev-logs-ui: Show logs for garage-ui in development +dev-logs-ui: + $(DOCKER_COMPOSE_DEV) logs -f garage-ui + +## dev-logs-garage: Show logs for garage in development +dev-logs-garage: + $(DOCKER_COMPOSE_DEV) logs -f garage + +## dev-shell: Open shell in garage-ui container (development) +dev-shell: + $(DOCKER_COMPOSE_DEV) exec garage-ui sh + +## prod-up: Start production environment +prod-up: + $(DOCKER_COMPOSE_PROD) up -d + +## prod-down: Stop production environment +prod-down: + $(DOCKER_COMPOSE_PROD) down + +## prod-restart: Restart production environment +prod-restart: prod-down prod-up + +## prod-logs: Show logs for production environment +prod-logs: + $(DOCKER_COMPOSE_PROD) logs -f + +## prod-logs-ui: Show logs for garage-ui in production +prod-logs-ui: + $(DOCKER_COMPOSE_PROD) logs -f garage-ui + +## prod-logs-garage: Show logs for garage in production +prod-logs-garage: + $(DOCKER_COMPOSE_PROD) logs -f garage + +## prod-pull: Pull latest images for production +prod-pull: + $(DOCKER_COMPOSE_PROD) pull + +## up: Start default (production) environment +up: prod-up + +## down: Stop all environments +down: + $(DOCKER_COMPOSE_PROD) down + $(DOCKER_COMPOSE_DEV) down + +## stop: Stop all containers +stop: + $(DOCKER_COMPOSE_PROD) stop + $(DOCKER_COMPOSE_DEV) stop + +## restart: Restart default (production) environment +restart: prod-restart + +## logs: Show logs for default (production) environment +logs: prod-logs + +## ps: Show running containers +ps: + docker ps -a --filter "name=garage" + +## health: Check health status of containers +health: + @echo "=== Container Status ===" + @docker ps --filter "name=garage" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + +## clean: Remove all containers, volumes, and images +clean: + $(DOCKER_COMPOSE_PROD) down -v --remove-orphans + $(DOCKER_COMPOSE_DEV) down -v --remove-orphans + docker system prune -f + +## clean-volumes: Remove all volumes (WARNING: deletes data) +clean-volumes: + $(DOCKER_COMPOSE_PROD) down -v + $(DOCKER_COMPOSE_DEV) down -v + rm -rf ./meta ./data + +## rebuild: Clean rebuild of development environment +rebuild: dev-down dev-build dev-up + +## install: Initial setup - create necessary directories +install: + @echo "Creating necessary directories..." + @mkdir -p meta data + @echo "Setup complete. Edit garage.toml and config.yaml before starting." + +## update: Pull latest changes and rebuild +update: prod-pull prod-restart + +.DEFAULT_GOAL := help diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 1a3fce6..5642417 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -33,7 +33,7 @@ type UserInfo struct { // NewAuthService creates a new authentication service func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) { - jwtService, err := NewJWTService() + jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey) if err != nil { return nil, fmt.Errorf("failed to initialize JWT service: %w", err) } diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 63c84b5..79cd982 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -1,9 +1,8 @@ package auth import ( + "crypto/ed25519" "crypto/rand" - "crypto/rsa" - "crypto/x509" "encoding/base64" "encoding/pem" "fmt" @@ -14,9 +13,10 @@ import ( ) type JWTService struct { - privateKey *rsa.PrivateKey - publicKey *rsa.PublicKey + privateKey ed25519.PrivateKey + publicKey ed25519.PublicKey stateStore *StateStore + mu sync.RWMutex } type StateStore struct { @@ -38,20 +38,53 @@ type SessionClaims struct { } func NewJWTService() (*JWTService, error) { - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, fmt.Errorf("failed to generate RSA key: %w", err) + return NewJWTServiceWithKey("") +} + +func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) { + var privateKey ed25519.PrivateKey + var publicKey ed25519.PublicKey + var err error + + if privateKeyPEM != "" { + // Parse the provided PEM-encoded private key + privateKey, err = parseEd25519PrivateKeyFromPEM(privateKeyPEM) + if err != nil { + return nil, fmt.Errorf("failed to parse Ed25519 private key: %w", err) + } + publicKey = privateKey.Public().(ed25519.PublicKey) + } else { + // Generate a new Ed25519 key pair if no key is provided + publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate Ed25519 key: %w", err) + } } return &JWTService{ privateKey: privateKey, - publicKey: &privateKey.PublicKey, + publicKey: publicKey, stateStore: &StateStore{ states: make(map[string]StateData), }, }, nil } +func parseEd25519PrivateKeyFromPEM(privateKeyPEM string) (ed25519.PrivateKey, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + // Check if it's raw Ed25519 private key bytes (64 bytes) + if len(block.Bytes) == ed25519.PrivateKeySize { + return ed25519.PrivateKey(block.Bytes), nil + } + + return nil, fmt.Errorf("invalid Ed25519 private key format: expected %d bytes, got %d", + ed25519.PrivateKeySize, len(block.Bytes)) +} + func (j *JWTService) GenerateStateToken() (string, error) { tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { @@ -105,6 +138,13 @@ func (j *JWTService) cleanupExpiredStates() { } func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (string, error) { + j.mu.RLock() + defer j.mu.RUnlock() + + if j.privateKey == nil { + return "", fmt.Errorf("private key not initialized") + } + now := time.Now() expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second) @@ -120,7 +160,7 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin }, } - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) tokenString, err := token.SignedString(j.privateKey) if err != nil { return "", fmt.Errorf("failed to sign token: %w", err) @@ -130,8 +170,15 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin } func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) { + j.mu.RLock() + defer j.mu.RUnlock() + + if j.publicKey == nil { + return nil, fmt.Errorf("public key not initialized") + } + token, err := jwt.ParseWithClaims(tokenString, &SessionClaims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return j.publicKey, nil @@ -149,15 +196,29 @@ func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) { } func (j *JWTService) GetPublicKeyPEM() (string, error) { - pubKeyBytes, err := x509.MarshalPKIXPublicKey(j.publicKey) - if err != nil { - return "", fmt.Errorf("failed to marshal public key: %w", err) + j.mu.RLock() + defer j.mu.RUnlock() + + if j.publicKey == nil { + return "", fmt.Errorf("public key not initialized") } pubKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: pubKeyBytes, + Type: "PUBLIC KEY", + Bytes: []byte(j.publicKey), }) return string(pubKeyPEM), nil } + +// GetPublicKeyBase64 returns the base64url-encoded public key for JWKS +func (j *JWTService) GetPublicKeyBase64() (string, error) { + j.mu.RLock() + defer j.mu.RUnlock() + + if j.publicKey == nil { + return "", fmt.Errorf("public key not initialized") + } + + return base64.RawURLEncoding.EncodeToString(j.publicKey), nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ab7deaf..0ee4df1 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -19,13 +19,17 @@ type Config struct { // ServerConfig contains server-related configuration type ServerConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Environment string `mapstructure:"environment"` - FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory - Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com) - Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https) - RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com) + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Environment string `mapstructure:"environment"` + FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory + Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com) + Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https) + RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com) + MaxBodySize int64 `mapstructure:"max_body_size"` // Maximum request body size in bytes (default: 300MB) + MaxHeaderSize int `mapstructure:"max_header_size"` // Maximum request header size in bytes (default: 1MB) + ReadBufferSize int `mapstructure:"read_buffer_size"` // Read buffer size in bytes (default: 4KB) + WriteBufferSize int `mapstructure:"write_buffer_size"` // Write buffer size in bytes (default: 4KB) } // GarageConfig contains Garage S3 connection settings @@ -40,8 +44,9 @@ type GarageConfig struct { // AuthConfig contains authentication configuration type AuthConfig struct { - Admin AdminAuthConfig `mapstructure:"admin"` - OIDC OIDCConfig `mapstructure:"oidc"` + Admin AdminAuthConfig `mapstructure:"admin"` + OIDC OIDCConfig `mapstructure:"oidc"` + JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes) } // AdminAuthConfig contains admin authentication settings @@ -110,8 +115,7 @@ func Load(configPath string) (*Config, error) { viper.SetEnvPrefix("GARAGE_UI") viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - // Bind environment variables to config keys - // This ensures env vars override config file values + // Env vars override config file values bindEnvVars() // Read the config file (optional - will use defaults and env vars if not found) @@ -145,6 +149,10 @@ func bindEnvVars() { viper.BindEnv("server.domain", "GARAGE_UI_SERVER_DOMAIN") viper.BindEnv("server.protocol", "GARAGE_UI_SERVER_PROTOCOL") viper.BindEnv("server.root_url", "GARAGE_UI_SERVER_ROOT_URL") + viper.BindEnv("server.max_body_size", "GARAGE_UI_SERVER_MAX_BODY_SIZE") + viper.BindEnv("server.max_header_size", "GARAGE_UI_SERVER_MAX_HEADER_SIZE") + viper.BindEnv("server.read_buffer_size", "GARAGE_UI_SERVER_READ_BUFFER_SIZE") + viper.BindEnv("server.write_buffer_size", "GARAGE_UI_SERVER_WRITE_BUFFER_SIZE") // Garage config viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT") @@ -158,6 +166,7 @@ func bindEnvVars() { viper.BindEnv("auth.admin.enabled", "GARAGE_UI_AUTH_ADMIN_ENABLED") viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME") viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD") + viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY") // OIDC config viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED") diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go index f44288b..b9f42e5 100644 --- a/backend/internal/handlers/buckets.go +++ b/backend/internal/handlers/buckets.go @@ -54,19 +54,24 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error { continue } + // Get detailed bucket info from Admin API to retrieve object count and size + detailedInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + // If we can't get detailed info, return basic info without stats + buckets = append(buckets, models.BucketInfo{ + Name: bucketName, + CreationDate: adminBucket.Created, + Region: "", + }) + continue + } + bucketInfo := models.BucketInfo{ Name: bucketName, CreationDate: adminBucket.Created, Region: "", // Garage doesn't have regions - } - - // Try to get bucket statistics (object count and size) - // This is done asynchronously to avoid blocking the response - // If it fails, we still return the bucket info without stats - stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName) - if err == nil && stats != nil { - bucketInfo.ObjectCount = &stats.ObjectCount - bucketInfo.Size = &stats.TotalSize + ObjectCount: &detailedInfo.Objects, + Size: &detailedInfo.Bytes, } buckets = append(buckets, bucketInfo) diff --git a/backend/internal/handlers/objects.go b/backend/internal/handlers/objects.go index b2414da..ccf8e8b 100644 --- a/backend/internal/handlers/objects.go +++ b/backend/internal/handlers/objects.go @@ -154,9 +154,14 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error { func (h *ObjectHandler) GetObject(c fiber.Ctx) error { ctx := c.Context() - // Get bucket name and object key from URL parameters + // Get bucket name from URL parameters bucketName := c.Params("bucket") - key := c.Params("key") + + // Get object key from locals (set by route handler) or from params + key, ok := c.Locals("objectKey").(string) + if !ok || key == "" { + key = c.Params("key") + } if bucketName == "" || key == "" { return c.Status(fiber.StatusBadRequest).JSON( @@ -205,9 +210,14 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error { func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error { ctx := c.Context() - // Get bucket name and object key from URL parameters + // Get bucket name from URL parameters bucketName := c.Params("bucket") - key := c.Params("key") + + // Get object key from locals (set by route handler) or from params + key, ok := c.Locals("objectKey").(string) + if !ok || key == "" { + key = c.Params("key") + } if bucketName == "" || key == "" { return c.Status(fiber.StatusBadRequest).JSON( @@ -262,9 +272,14 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error { func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error { ctx := c.Context() - // Get bucket name and object key from URL parameters + // Get bucket name from URL parameters bucketName := c.Params("bucket") - key := c.Params("key") + + // Get object key from locals (set by route handler) or from params + key, ok := c.Locals("objectKey").(string) + if !ok || key == "" { + key = c.Params("key") + } if bucketName == "" || key == "" { return c.Status(fiber.StatusBadRequest).JSON( @@ -301,9 +316,14 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error { func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error { ctx := c.Context() - // Get bucket name and object key from URL parameters + // Get bucket name from URL parameters bucketName := c.Params("bucket") - key := c.Params("key") + + // Get object key from locals (set by route handler) or from params + key, ok := c.Locals("objectKey").(string) + if !ok || key == "" { + key = c.Params("key") + } if bucketName == "" || key == "" { return c.Status(fiber.StatusBadRequest).JSON( diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index cde9aec..3256074 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -8,8 +8,7 @@ import ( "github.com/gofiber/fiber/v3" ) -// AuthMiddleware returns a Fiber middleware for authentication -// It handles multiple auth methods: admin and OIDC +// AuthMiddleware supports admin and OIDC authentication func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler { return func(c fiber.Ctx) error { // If no auth is enabled, allow all requests diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go index bf9f513..8ded350 100644 --- a/backend/internal/models/responses.go +++ b/backend/internal/models/responses.go @@ -41,8 +41,8 @@ type HealthResponse struct { // BucketInfo represents information about a bucket type BucketInfo struct { Name string `json:"name"` - CreationDate time.Time `json:"creation_date"` - ObjectCount *int64 `json:"object_count,omitempty"` + CreationDate time.Time `json:"creationDate"` + ObjectCount *int64 `json:"objectCount,omitempty"` Size *int64 `json:"size,omitempty"` Region string `json:"region,omitempty"` } @@ -55,12 +55,13 @@ type BucketListResponse struct { // ObjectInfo represents information about an object type ObjectInfo struct { - Key string `json:"key"` - Size int64 `json:"size"` - LastModified time.Time `json:"last_modified"` - ETag string `json:"etag"` - ContentType string `json:"content_type,omitempty"` - StorageClass string `json:"storage_class,omitempty"` + Key string `json:"key"` + Size int64 `json:"size"` + LastModified time.Time `json:"last_modified"` + ETag string `json:"etag"` + ContentType string `json:"content_type,omitempty"` + StorageClass string `json:"storage_class,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } // ObjectListResponse represents a list of objects in a bucket @@ -120,7 +121,6 @@ type UserInfo struct { Name string `json:"name"` SecretKey *string `json:"secretKey,omitempty"` CreatedAt *time.Time `json:"createdAt,omitempty"` - LastUsed *time.Time `json:"lastUsed,omitempty"` Status string `json:"status"` // "active" or "inactive" BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions Expiration *time.Time `json:"expiration,omitempty"` diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 22dbd59..946858b 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -6,6 +6,7 @@ import ( "Noooste/garage-ui/internal/handlers" "Noooste/garage-ui/internal/middleware" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -68,12 +69,74 @@ func SetupRoutes( objects.Post("/", objectHandler.UploadObject) // Upload object (multipart) objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects - objects.Get("/:key", objectHandler.GetObject) // Download object - objects.Delete("/:key", objectHandler.DeleteObject) // Delete object - objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata - objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL } + // Object-specific routes with wildcard key parameter (supports paths with slashes) + // These need to be registered on the main app with auth middleware applied + objectWildcardHandler := func(c fiber.Ctx) error { + // Get the full path from wildcard parameter + // Note: Fiber v3 does NOT automatically decode params, we need to do it manually + path := c.Params("*") + + // Decode the full path using QueryUnescape (handles %20, %2F, etc.) + decodedPath, err := url.QueryUnescape(path) + if err != nil { + // If decoding fails, use the original path + decodedPath = path + } + + // Check if it's a metadata request + if strings.HasSuffix(decodedPath, "/metadata") { + // Remove /metadata suffix to get the actual key + key := strings.TrimSuffix(decodedPath, "/metadata") + c.Locals("objectKey", key) + return objectHandler.GetObjectMetadata(c) + } + // Check if it's a presign request + if strings.HasSuffix(decodedPath, "/presign") { + // Remove /presign suffix to get the actual key + key := strings.TrimSuffix(decodedPath, "/presign") + c.Locals("objectKey", key) + return objectHandler.GetPresignedURL(c) + } + // Otherwise, it's a regular object download + c.Locals("objectKey", decodedPath) + return objectHandler.GetObject(c) + } + + objectDeleteHandler := func(c fiber.Ctx) error { + path := c.Params("*") + + // Decode the full path using QueryUnescape + key, err := url.QueryUnescape(path) + if err != nil { + // If decoding fails, use the original path + key = path + } + + c.Locals("objectKey", key) + return objectHandler.DeleteObject(c) + } + + objectHeadHandler := func(c fiber.Ctx) error { + path := c.Params("*") + + // Decode the full path using QueryUnescape + key, err := url.QueryUnescape(path) + if err != nil { + // If decoding fails, use the original path + key = path + } + + c.Locals("objectKey", key) + return objectHandler.GetObjectMetadata(c) + } + + // Register with auth middleware + app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler) + app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler) + app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler) + // User/Key management routes users := api.Group("/users") { @@ -197,7 +260,7 @@ func SetupRoutes( }) // Redirect to frontend with success indicator - return c.Redirect().To("/?login=success") + return c.Redirect().To("/login?login=success") }) // Logout endpoint diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go index ced1ebc..269243a 100644 --- a/backend/internal/services/s3.go +++ b/backend/internal/services/s3.go @@ -52,8 +52,6 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S } } -// getBucketCredentials retrieves credentials for a specific bucket -// It checks the cache first, then queries the Garage Admin API func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) { cacheKey := fmt.Sprintf("key:%s", bucketName) cacheData := utils.GlobalCache.Get(cacheKey) @@ -204,64 +202,72 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys = 1000 } - // Use ListObjectsV2 for proper pagination support - opts := minio.ListObjectsOptions{ - Prefix: prefix, - Recursive: false, - MaxKeys: maxKeys, - StartAfter: continuationToken, - UseV1: false, + // Create Core client for low-level API access + core := &minio.Core{Client: client} + + // Use Core.ListObjectsV2 for proper pagination with continuation tokens + result, err := core.ListObjectsV2( + bucketName, + prefix, // objectPrefix + "", // startAfter (empty when using continuationToken) + continuationToken, // continuationToken (proper S3 token) + "/", // delimiter (for folder listing) + maxKeys, // maxkeys + ) + + if err != nil { + return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err) } - objects := make([]models.ObjectInfo, 0) - prefixesMap := make(map[string]bool) + // Process objects from result.Contents + // Note: ListObjectsV2 doesn't return ContentType, so we need to fetch it separately + objects := make([]models.ObjectInfo, len(result.Contents)) - var lastKey string - isTruncated := false - itemCount := 0 + // Use goroutines to fetch ContentType concurrently for better performance + type statResult struct { + index int + contentType string + err error + } - // List objects using the channel-based API - for object := range client.ListObjects(ctx, bucketName, opts) { - if object.Err != nil { - return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, object.Err) - } + statChan := make(chan statResult, len(result.Contents)) - // Check if this is a prefix (directory) - if len(object.Key) > 0 && object.Key[len(object.Key)-1:] == "/" && object.Size == 0 { - prefixesMap[object.Key] = true - continue - } + for i, obj := range result.Contents { + go func(idx int, objKey string) { + // Fetch object metadata to get ContentType + stat, err := client.StatObject(ctx, bucketName, objKey, minio.StatObjectOptions{}) + if err != nil { + // If StatObject fails, we still include the object but without ContentType + statChan <- statResult{index: idx, contentType: "", err: err} + return + } + statChan <- statResult{index: idx, contentType: stat.ContentType, err: nil} + }(i, obj.Key) - // Track the last key for pagination - lastKey = object.Key - - // Add to objects list - objects = append(objects, models.ObjectInfo{ - Key: object.Key, - Size: object.Size, - LastModified: object.LastModified, - ETag: object.ETag, - ContentType: object.ContentType, - StorageClass: object.StorageClass, - }) - - itemCount++ - if itemCount >= maxKeys { - isTruncated = true - break + // Initialize the object with basic info from ListObjectsV2 + objects[i] = models.ObjectInfo{ + Key: obj.Key, + Size: obj.Size, + LastModified: obj.LastModified, + ETag: obj.ETag, + StorageClass: obj.StorageClass, } } - // Convert prefixes map to slice - prefixList := make([]string, 0, len(prefixesMap)) - for p := range prefixesMap { - prefixList = append(prefixList, p) + // Collect results from goroutines + for range result.Contents { + res := <-statChan + if res.err == nil { + objects[res.index].ContentType = res.contentType + } + // If there was an error, ContentType remains empty, which is acceptable } + close(statChan) - // Prepare next continuation token - var nextToken string - if isTruncated && lastKey != "" { - nextToken = lastKey + // Process folders from result.CommonPrefixes + prefixList := make([]string, 0, len(result.CommonPrefixes)) + for _, p := range result.CommonPrefixes { + prefixList = append(prefixList, p.Prefix) } return &models.ObjectListResponse{ @@ -269,8 +275,8 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, Objects: objects, Prefixes: prefixList, Count: len(objects), - IsTruncated: isTruncated, - NextContinuationToken: nextToken, + IsTruncated: result.IsTruncated, + NextContinuationToken: result.NextContinuationToken, }, nil } @@ -412,6 +418,8 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin LastModified: stat.LastModified, ETag: stat.ETag, ContentType: stat.ContentType, + StorageClass: stat.StorageClass, + Metadata: stat.UserMetadata, }, nil } @@ -488,9 +496,6 @@ type UploadResult struct { ContentType string } -// UploadMultipleObjects uploads multiple objects to a bucket -// It handles uploads in batches to respect any S3/Garage limits -// Returns results for each file, including both successes and failures func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct { Key string Body io.Reader diff --git a/backend/main.go b/backend/main.go index 49c8029..01a1c8f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -103,12 +103,35 @@ func main() { clusterHandler := handlers.NewClusterHandler(adminService) monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service) + // Set default values for buffer sizes if not configured + maxBodySize := cfg.Server.MaxBodySize + if maxBodySize == 0 { + maxBodySize = 300 * 1024 * 1024 // 300MB default + } + maxHeaderSize := cfg.Server.MaxHeaderSize + if maxHeaderSize == 0 { + maxHeaderSize = 1 * 1024 * 1024 // 1MB default + } + readBufferSize := cfg.Server.ReadBufferSize + if readBufferSize == 0 { + readBufferSize = 4096 // 4KB default + } + writeBufferSize := cfg.Server.WriteBufferSize + if writeBufferSize == 0 { + writeBufferSize = 4096 // 4KB default + } + + log.Printf("Server limits - Max body: %d bytes (%.2fMB), Max header: %d bytes (%.2fKB)", + maxBodySize, float64(maxBodySize)/(1024*1024), + maxHeaderSize, float64(maxHeaderSize)/1024) + // Create Fiber app with configuration app := fiber.New(fiber.Config{ - AppName: "Garage UI Backend v" + version, - //DisableStartupMessage: false, - //EnablePrintRoutes: cfg.IsDevelopment(), - ErrorHandler: customErrorHandler, + AppName: "Garage UI Backend v" + version, + BodyLimit: int(maxBodySize), + ReadBufferSize: readBufferSize, + WriteBufferSize: writeBufferSize, + ErrorHandler: customErrorHandler, }) // Apply global middleware diff --git a/config.yaml.example b/config.yaml.example index 891a987..b8bb940 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -9,6 +9,12 @@ server: protocol: "http" # Protocol for internal communication (http/https) root_url: "http://localhost:8080" # Full external URL for OAuth2 redirects (adjust for production) + # Request size limits (in bytes) + max_body_size: 314572800 # 300MB - Maximum request body size (increase for large file uploads) + max_header_size: 1048576 # 1MB - Maximum request header size + read_buffer_size: 4096 # 4KB - Read buffer size + write_buffer_size: 4096 # 4KB - Write buffer size + # Garage S3 Configuration garage: endpoint: "http://localhost:3900" # Garage S3 API endpoint @@ -21,6 +27,13 @@ garage: # Authentication Configuration # You can enable one or both authentication methods auth: + # JWT Configuration + # Ed25519 private key in PEM format for JWT token signing + # If not specified, a new key will be generated on each startup (tokens won't persist across restarts) + # Generate with: openssl genpkey -algorithm ED25519 -out jwt-key.pem + # The key is a 64-byte Ed25519 private key + jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key + # Admin Authentication (username/password) admin: enabled: false # Set to true to enable admin login diff --git a/docker-compose.yml b/docker-compose.yml index f802e94..4079fbc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,14 @@ services: GARAGE_UI_SERVER_PORT: "8080" GARAGE_UI_SERVER_ENVIRONMENT: "production" + # JWT Configuration + # IMPORTANT: Replace this with your own Ed25519 private key in production! + # Generate with: openssl genpkey -algorithm ED25519 + GARAGE_UI_AUTH_JWT_PRIVATE_KEY: | + -----BEGIN PRIVATE KEY----- + MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS + -----END PRIVATE KEY----- + # Logging GARAGE_UI_LOGGING_LEVEL: "info" GARAGE_UI_LOGGING_FORMAT: "json" diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 770b2f3..aeb7d5b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import {Buckets} from '@/pages/Buckets'; import {Cluster} from '@/pages/Cluster'; import {AccessControl} from '@/pages/AccessControl'; import {Login} from '@/pages/Login'; +import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView'; import {Toaster} from 'sonner'; import {queryClient} from '@/lib/query-client'; import {useAuthStore} from '@/store/auth-store'; @@ -48,6 +49,7 @@ function App() { > } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/auth/BasicLoginForm.tsx b/frontend/src/components/auth/BasicLoginForm.tsx index 7c4dcfa..4d337f2 100644 --- a/frontend/src/components/auth/BasicLoginForm.tsx +++ b/frontend/src/components/auth/BasicLoginForm.tsx @@ -3,8 +3,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuthStore } from '@/store/auth-store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Lock, LogIn } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { LogIn } from 'lucide-react'; import type { AuthConfig } from '@/types/auth'; interface BasicLoginFormProps { @@ -43,16 +43,15 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps
-
- -
+ Garage Logo
- {showOIDC ? 'Sign in to Garage UI' : 'Admin Login'} + Welcome to Garage UI - - {showOIDC ? 'Enter your credentials or use SSO' : 'Enter your credentials to access the dashboard'} -
@@ -94,11 +93,8 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps {showOIDC && (
-
- -
-
- Or +
+ or
{/* Upload Zone */} - {showUploadZone && ( + {showUploadZone && uploadTasks.length === 0 && (
@@ -312,6 +315,9 @@ export function ObjectBrowserView({
)} + {/* Upload Progress */} + {uploadTasks.length > 0 && } + {/* Objects Table with Drag & Drop */}
(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!bucketName || !objectKey) { + setError('Bucket name and object key are required'); + setIsLoading(false); + return; + } + + const fetchMetadata = async () => { + try { + setIsLoading(true); + setError(null); + const data = await objectsApi.getMetadata(bucketName, objectKey); + setMetadata(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load object metadata'); + console.error('Failed to fetch object metadata:', err); + } finally { + setIsLoading(false); + } + }; + + fetchMetadata(); + }, [bucketName, objectKey]); + + const handleDownload = async () => { + if (!bucketName || !objectKey) return; + + try { + const blob = await objectsApi.get(bucketName, objectKey); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = objectKey.split('/').pop() || 'download'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Download started'); + } catch (err) { + console.error('Download failed:', err); + } + }; + + const handleDelete = async () => { + if (!bucketName || !objectKey) return; + + if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) { + return; + } + + try { + await objectsApi.delete(bucketName, objectKey); + toast.success('Object deleted successfully'); + handleBackNavigation(); + } catch (err) { + console.error('Delete failed:', err); + } + }; + + const handleBackNavigation = () => { + if (!bucketName) return; + + // Navigate back to the bucket explorer with the appropriate prefix + // Extract the folder path from the object key (everything before the last /) + const folderPath = objectKey?.split('/').slice(0, -1).join('/') || ''; + const prefix = folderPath ? `${folderPath}/` : ''; + + // Navigate to the bucket view with the correct prefix + navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`); + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + }); + }; + + if (isLoading) { + return ( +
+
+
+
+
Loading object details...
+
+
+
+ ); + } + + if (error || !metadata) { + return ( +
+
+
+ +
+
{error || 'Object not found'}
+
+
+
+ ); + } + + const fileName = objectKey?.split('/').pop() || objectKey || ''; + const pathParts = objectKey?.split('/').filter(part => part) || []; + const parentPath = pathParts.slice(0, -1).join('/'); + + return ( +
+
+
+ {/* Back Button and Actions */} +
+ +
+ + +
+
+ + {/* File Name Header */} +
+
+ +
+
+
+

+ {parentPath && ( + /{parentPath}/ + )} + {fileName} +

+ +
+
+
+ + {/* Object Details Section */} +
+
+

Object Details

+
+
+
+
Date Created
+
+ {formatDate(metadata.lastModified)} +
+
+
+
Type
+
+ {metadata.contentType || 'application/octet-stream'} +
+
+
+
Storage Class
+
+ {metadata.storageClass || 'Standard'} +
+
+
+
Size
+
+ {formatBytes(metadata.size)} +
+
+
+
+ + {/* Custom Metadata Section */} + {metadata.metadata && Object.keys(metadata.metadata).length > 0 && ( +
+
+

Custom Metadata

+
+
+ + + + + + + + + {Object.entries(metadata.metadata).map(([key, value]) => ( + + + + + ))} + +
+ Key + + Value +
+ {key} + {value}
+
+
+ )} + + {/* Object Preview Section */} +
+
+

Object Preview

+
+
+

No preview available

+
+
+
+
+ ); +} diff --git a/frontend/src/components/buckets/ObjectsTable.tsx b/frontend/src/components/buckets/ObjectsTable.tsx index f196fa4..cf13a0c 100644 --- a/frontend/src/components/buckets/ObjectsTable.tsx +++ b/frontend/src/components/buckets/ObjectsTable.tsx @@ -1,4 +1,5 @@ import {useEffect, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Checkbox} from '@/components/ui/checkbox'; @@ -11,13 +12,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react'; +import {ChevronLeft, ChevronRight, Download, Eye, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react'; import {Select, SelectOption} from '@/components/ui/select'; -import {formatBytes} from '@/lib/utils'; -import {formatRelativeTime, getFileType} from '@/lib/file-utils'; +import {formatBytes, formatRelativeTime} from '@/lib/file-utils'; import type {S3Object} from '@/types'; interface ObjectsTableProps { + bucketName: string; objects: S3Object[]; currentPath: string; searchQuery: string; @@ -41,6 +42,7 @@ type SortColumn = 'name' | 'size' | 'modified'; type SortDirection = 'asc' | 'desc'; export function ObjectsTable({ + bucketName, objects, currentPath, searchQuery, @@ -59,6 +61,7 @@ export function ObjectsTable({ initialPageToken, initialItemsPerPage, }: ObjectsTableProps) { + const navigate = useNavigate(); const [sortColumn, setSortColumn] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); const [filteredObjects, setFilteredObjects] = useState([]); @@ -117,16 +120,22 @@ export function ObjectsTable({ return sorted; }; + // Effect 1: Apply client-side filtering and sorting (NO pagination reset) useEffect(() => { const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(searchQuery.toLowerCase()) ); const sorted = sortObjects(filtered); setFilteredObjects(sorted); - // Reset pagination when path/search changes + + // Do NOT reset pagination - search/sort are client-side operations + }, [searchQuery, objects, sortColumn, sortDirection]); + + // Effect 2: Reset pagination ONLY on path navigation + useEffect(() => { setPageTokens([undefined]); setCurrentPageIndex(0); - }, [searchQuery, objects, sortColumn, sortDirection, currentPath]); + }, [currentPath]); // Update page tokens when we get a new next token useEffect(() => { @@ -273,14 +282,17 @@ export function ObjectsTable({ {obj.key.replace(currentPath, '').replace('/', '')} ) : ( - + )}
- {obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))} + {obj.isFolder ? 'Directory' : (obj.contentType || 'application/octet-stream')} {obj.storageClass && ( @@ -349,6 +361,10 @@ export function ObjectsTable({ + navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}> + + View Details + Download diff --git a/frontend/src/components/buckets/UploadProgress.tsx b/frontend/src/components/buckets/UploadProgress.tsx new file mode 100644 index 0000000..29219a7 --- /dev/null +++ b/frontend/src/components/buckets/UploadProgress.tsx @@ -0,0 +1,115 @@ +import { CheckCircle, Upload, AlertCircle, Loader2 } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import type { UploadTask } from '@/types'; + +interface UploadProgressProps { + tasks: UploadTask[]; +} + +export function UploadProgress({ tasks }: UploadProgressProps) { + if (tasks.length === 0) return null; + + const completedCount = tasks.filter(t => t.status === 'completed').length; + const errorCount = tasks.filter(t => t.status === 'error').length; + const totalCount = tasks.length; + const processedCount = completedCount + errorCount; + const allDone = processedCount === totalCount; + + // Find currently uploading file + const currentFile = tasks.find(t => t.status === 'uploading'); + const currentFileName = currentFile?.key.split('/').pop() || currentFile?.key || 'Processing...'; + + // File-based progress plus contribution from current upload + const baseProgress = (processedCount / totalCount) * 100; + const currentFileContribution = currentFile + ? (currentFile.progress / 100) * (1 / totalCount) * 100 + : 0; + const overallProgress = Math.min(baseProgress + currentFileContribution, 100); + + return ( + + +
+ {/* Header with icon and status */} +
+
+
0 + ? 'bg-yellow-500/10 dark:bg-yellow-500/20' + : 'bg-primary/10 dark:bg-primary/20' + }`}> + {allDone ? ( + + ) : errorCount > 0 ? ( + + ) : ( + + )} +
+
+
+ {allDone ? 'Upload Complete' : 'Uploading Files'} +
+
+ {processedCount} of {totalCount} files +
+
+
+
+
+ {Math.round(overallProgress)}% +
+
+
+ + {/* Progress bar with gradient */} +
+ {!allDone && currentFile && ( +
+ + + {currentFileName} + +
+ )} +
+
+ {/* Animated shimmer effect */} + {!allDone && overallProgress > 0 && ( +
+ )} +
+
+
+ + {/* Error indicator with icon */} + {errorCount > 0 && ( +
+ + + {errorCount} file{errorCount > 1 ? 's' : ''} failed to upload + +
+ )} + + {/* Success message */} + {allDone && errorCount === 0 && ( +
+ + All files uploaded successfully +
+ )} +
+ + + ); +} diff --git a/frontend/src/components/charts/BucketUsageChart.tsx b/frontend/src/components/charts/BucketUsageChart.tsx index 470e9f3..4211889 100644 --- a/frontend/src/components/charts/BucketUsageChart.tsx +++ b/frontend/src/components/charts/BucketUsageChart.tsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react'; import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts'; import type {BucketUsage} from '@/types'; -import {formatBytes} from '@/lib/utils'; +import {formatBytes} from '@/lib/file-utils'; import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors'; interface BucketUsageChartProps { diff --git a/frontend/src/components/charts/ClusterHealthChart.tsx b/frontend/src/components/charts/ClusterHealthChart.tsx deleted file mode 100644 index 486ee0e..0000000 --- a/frontend/src/components/charts/ClusterHealthChart.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import {useEffect, useState} from 'react'; -import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; -import type {ClusterHealth} from '@/types'; -import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors'; - -interface ClusterHealthChartProps { - data: ClusterHealth; -} - -export function ClusterHealthChart({ data }: ClusterHealthChartProps) { - const [isDark, setIsDark] = useState(false); - - useEffect(() => { - const checkDarkMode = () => { - setIsDark(document.documentElement.classList.contains('dark')); - }; - - checkDarkMode(); - - const observer = new MutationObserver(checkDarkMode); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); - - return () => observer.disconnect(); - }, []); - - const colors = isDark ? grafanaColors.dark : grafanaColors.light; - const textColor = getTextColor(isDark); - const gridColor = getGridColor(isDark); - const tooltipStyle = getTooltipStyle(isDark); - const unhealthyColor = isDark ? '#e8e8e8' : '#d1d5db'; - - const chartData = [ - { - metric: 'Nodes', - healthy: data.storageNodesUp, - unhealthy: data.storageNodes - data.storageNodesUp, - }, - { - metric: 'Partitions', - healthy: data.partitionsAllOk, - unhealthy: data.partitions - data.partitionsAllOk, - }, - { - metric: 'Connected', - healthy: data.connectedNodes, - unhealthy: data.knownNodes - data.connectedNodes, - }, - ]; - - return ( - - - - - - - - - - - - ); -} diff --git a/frontend/src/components/charts/RequestMetricsChart.tsx b/frontend/src/components/charts/RequestMetricsChart.tsx deleted file mode 100644 index 9f25209..0000000 --- a/frontend/src/components/charts/RequestMetricsChart.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useEffect, useState } from 'react'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; -import type { RequestMetrics } from '@/types'; -import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors'; - -interface RequestMetricsChartProps { - data: RequestMetrics; -} - -export function RequestMetricsChart({ data }: RequestMetricsChartProps) { - const [isDark, setIsDark] = useState(false); - - useEffect(() => { - const checkDarkMode = () => { - setIsDark(document.documentElement.classList.contains('dark')); - }; - - checkDarkMode(); - - const observer = new MutationObserver(checkDarkMode); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); - - return () => observer.disconnect(); - }, []); - - const colors = isDark ? grafanaColors.dark : grafanaColors.light; - const textColor = getTextColor(isDark); - const gridColor = getGridColor(isDark); - const tooltipStyle = getTooltipStyle(isDark); - - const chartData = [ - { - name: 'Requests (24h)', - GET: data.getRequests, - PUT: data.putRequests, - DELETE: data.deleteRequests, - LIST: data.listRequests, - }, - ]; - - return ( - - - - - - (value as number).toLocaleString()} - contentStyle={tooltipStyle as React.CSSProperties} - labelStyle={{ color: textColor }} - /> - - - - - - - - ); -} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index cc7deb4..f7baefe 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,3 +1,2 @@ -export { useDashboardData } from './useApi'; -export { useBuckets } from './useBuckets'; +export { useDashboardData, useBuckets } from './useApi'; export { useBucketObjects } from './useBucketObjects'; diff --git a/frontend/src/hooks/useBucketObjects.ts b/frontend/src/hooks/useBucketObjects.ts index b19df14..44846c4 100644 --- a/frontend/src/hooks/useBucketObjects.ts +++ b/frontend/src/hooks/useBucketObjects.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { objectsApi } from '@/lib/api'; -import type { S3Object } from '@/types'; +import type { S3Object, UploadTask } from '@/types'; import { toast } from 'sonner'; export function useBucketObjects(bucketName: string | null, currentPath: string = '') { @@ -14,6 +14,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string const [itemsPerPage, setItemsPerPage] = useState(25); const [currentContinuationToken, setCurrentContinuationToken] = useState(undefined); const [previousPath, setPreviousPath] = useState(currentPath); + const [uploadTasks, setUploadTasks] = useState([]); const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => { if (!bucketName) return; @@ -57,41 +58,103 @@ export function useBucketObjects(bucketName: string | null, currentPath: string const uploadFiles = useCallback(async (files: File[]) => { if (!bucketName) return false; - try { - // Check if files are from a folder upload - const hasRelativePaths = files.some((file: any) => file.webkitRelativePath); + // Check if files are from a folder upload + const hasRelativePaths = files.some((file: any) => file.webkitRelativePath); - // Get unique folders from the files - const folders = new Set(); - files.forEach((file: any) => { - if (file.webkitRelativePath) { - const parts = file.webkitRelativePath.split('/'); - if (parts.length > 1) { - folders.add(parts[0]); - } + // Get unique folders from the files + const folders = new Set(); + files.forEach((file: any) => { + if (file.webkitRelativePath) { + const parts = file.webkitRelativePath.split('/'); + if (parts.length > 1) { + folders.add(parts[0]); + } + } + }); + + // Initialize upload tasks + const tasks: UploadTask[] = files.map((file, index) => { + const relativePath = (file as any).webkitRelativePath || file.name; + const key = currentPath ? `${currentPath}${relativePath}` : relativePath; + return { + id: `${Date.now()}-${index}`, + file, + key, + bucket: bucketName, + progress: 0, + status: 'pending' as const, + }; + }); + + setUploadTasks(tasks); + + // Upload files with progress tracking and error handling + let successCount = 0; + let errorCount = 0; + + // Upload files one by one + const concurrency = 1; + const uploadPromises: Promise[] = []; + + for (let i = 0; i < tasks.length; i += concurrency) { + const batch = tasks.slice(i, Math.min(i + concurrency, tasks.length)); + + const batchPromises = batch.map(async (task) => { + try { + // Update task status to uploading + setUploadTasks(prev => prev.map(t => + t.id === task.id ? { ...t, status: 'uploading' as const } : t + )); + + await objectsApi.upload(bucketName, task.key, task.file, (progress) => { + setUploadTasks(prev => prev.map(t => + t.id === task.id ? { ...t, progress } : t + )); + }); + + // Update task status to completed + setUploadTasks(prev => prev.map(t => + t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t + )); + successCount++; + } catch (error) { + // Update task status to error but continue with other uploads + const errorMessage = error instanceof Error ? error.message : 'Upload failed'; + setUploadTasks(prev => prev.map(t => + t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t + )); + errorCount++; + console.error(`Failed to upload ${task.key}:`, error); } }); - for (const file of files) { - // Use webkitRelativePath if available (for folder uploads), otherwise use file.name - const relativePath = (file as any).webkitRelativePath || file.name; - const key = currentPath ? `${currentPath}${relativePath}` : relativePath; - await objectsApi.upload(bucketName, key, file); - } + uploadPromises.push(...batchPromises); + await Promise.all(batchPromises); + } + await Promise.all(uploadPromises); + + // Show summary toast + if (errorCount === 0) { if (hasRelativePaths && folders.size > 0) { const folderNames = Array.from(folders).join(', '); - toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`); + toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`); } else { - toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`); + toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''}`); } - - await fetchObjects(currentContinuationToken, true); - return true; - } catch (error) { - console.error('Upload error:', error); - return false; + } else if (successCount > 0) { + toast.warning(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}, ${errorCount} failed`); + } else { + toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`); } + + // Clear upload tasks after a delay + setTimeout(() => { + setUploadTasks([]); + }, 3000); + + await fetchObjects(currentContinuationToken, true); + return successCount > 0; }, [bucketName, currentPath, currentContinuationToken, fetchObjects]); const deleteObject = useCallback(async (key: string) => { @@ -160,6 +223,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string setItemsPerPage, fetchObjects, uploadFiles, + uploadTasks, deleteObject, deleteMultipleObjects, createDirectory, diff --git a/frontend/src/hooks/useBuckets.ts b/frontend/src/hooks/useBuckets.ts deleted file mode 100644 index 43001a8..0000000 --- a/frontend/src/hooks/useBuckets.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { bucketsApi } from '@/lib/api'; -import type { Bucket } from '@/types'; -import { toast } from 'sonner'; - -export function useBuckets() { - const [buckets, setBuckets] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchBuckets = useCallback(async () => { - try { - setIsLoading(true); - setError(null); - const data = await bucketsApi.list(); - setBuckets(data); - } catch (err) { - setError(err as Error); - console.error('Failed to fetch buckets:', err); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - fetchBuckets(); - }, [fetchBuckets]); - - const createBucket = useCallback(async (name: string, region?: string) => { - try { - await bucketsApi.create(name, region); - toast.success(`Bucket "${name}" created successfully`); - await fetchBuckets(); - return true; - } catch (error) { - console.error('Create bucket error:', error); - return false; - } - }, [fetchBuckets]); - - const deleteBucket = useCallback(async (name: string) => { - try { - await bucketsApi.delete(name); - toast.success(`Bucket "${name}" deleted successfully`); - await fetchBuckets(); - return true; - } catch (error) { - console.error('Delete bucket error:', error); - return false; - } - }, [fetchBuckets]); - - const grantPermission = useCallback(async ( - bucketName: string, - accessKeyId: string, - permissions: { read: boolean; write: boolean; owner: boolean } - ) => { - try { - await bucketsApi.grantPermission(bucketName, accessKeyId, permissions); - toast.success('Permissions granted successfully'); - return true; - } catch (error) { - console.error('Grant permission error:', error); - return false; - } - }, []); - - return { - buckets, - isLoading, - error, - fetchBuckets, - createBucket, - deleteBucket, - grantPermission, - }; -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2913ded..0d41694 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,6 +17,12 @@ import type { } from '@/types'; import type { AuthUser } from '@/types/auth'; +// Helper function to encode object keys for URLs +// Encodes the entire key including slashes to ensure proper handling of special characters +const encodeObjectKey = (key: string): string => { + return encodeURIComponent(key); +}; + const api = axios.create({ baseURL: '/api', headers: { @@ -207,6 +213,7 @@ export const objectsApi = { size: obj.size, lastModified: obj.last_modified, etag: obj.etag, + contentType: obj.content_type, storageClass: obj.storage_class, isFolder: false, })) || []; @@ -229,23 +236,38 @@ export const objectsApi = { }, get: async (bucket: string, key: string): Promise => { - const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, { + const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, { responseType: 'blob' }); return response.data; }, getMetadata: async (bucket: string, key: string): Promise => { - const response = await api.head(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`); - return response.data.data; + const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/metadata`); + const data = response.data.data; + return { + key: data.key, + size: data.size, + lastModified: data.last_modified, + contentType: data.content_type, + etag: data.etag, + storageClass: data.storage_class, + metadata: data.metadata, + }; }, - upload: async (bucket: string, key: string, file: File): Promise => { + upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): Promise => { const formData = new FormData(); formData.append('file', file); formData.append('key', key); await api.post(`/v1/buckets/${bucket}/objects`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(progress); + } + }, }); }, @@ -262,7 +284,7 @@ export const objectsApi = { }, delete: async (bucket: string, key: string): Promise => { - await api.delete(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`); + await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`); }, deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise => { @@ -271,7 +293,7 @@ export const objectsApi = { }, getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise => { - const response = await api.post(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}/presign`, {}, { + const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/presign`, { params: { expires_in: expiresIn } }); return response.data.data.url; diff --git a/frontend/src/lib/file-utils.ts b/frontend/src/lib/file-utils.ts index 7055123..f9a015b 100644 --- a/frontend/src/lib/file-utils.ts +++ b/frontend/src/lib/file-utils.ts @@ -98,3 +98,18 @@ export function formatRelativeTime(date: Date): string { if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) !== 1 ? 's' : ''} ago`; return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`; } + +/** + * Format bytes to human-readable size + */ +export function formatBytes(bytes: number, decimals = 2): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 20d86e0..51b8b15 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -5,18 +5,6 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function formatBytes(bytes: number, decimals = 2): string { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; -} - export function formatDate(date: Date | string): string { const d = typeof date === 'string' ? new Date(date) : date; return new Intl.DateTimeFormat('en-US', { diff --git a/frontend/src/pages/AccessControl.tsx b/frontend/src/pages/AccessControl.tsx index 5a8753a..0a8dd33 100644 --- a/frontend/src/pages/AccessControl.tsx +++ b/frontend/src/pages/AccessControl.tsx @@ -58,12 +58,6 @@ export function AccessControl() { const [permissionOwner, setPermissionOwner] = useState(false); // Key settings state (activation/expiration) - // const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); - // const [settingsKey, setSettingsKey] = useState(null); - // const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active'); - // const [expirationDate, setExpirationDate] = useState(''); - // const [neverExpires, setNeverExpires] = useState(true); - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [settingsKey, setSettingsKey] = useState(null); const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active'); @@ -75,6 +69,14 @@ export function AccessControl() { const [revealedSecretKey, setRevealedSecretKey] = useState(''); const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false); + // Key details dialog state + const [keyDetailsDialogOpen, setKeyDetailsDialogOpen] = useState(false); + const [viewingKey, setViewingKey] = useState(null); + const [detailsSecretKey, setDetailsSecretKey] = useState(''); + const [isLoadingDetailsSecretKey, setIsLoadingDetailsSecretKey] = useState(false); + const [copiedAccessKeyId, setCopiedAccessKeyId] = useState(false); + const [copiedSecretKey, setCopiedSecretKey] = useState(false); + useEffect(() => { const fetchKeys = async () => { try { @@ -341,6 +343,25 @@ export function AccessControl() { return perms.join(', ') || 'None'; }; + const handleRowClick = async (key: AccessKey) => { + setViewingKey(key); + setKeyDetailsDialogOpen(true); + setDetailsSecretKey(''); + setIsLoadingDetailsSecretKey(true); + setCopiedAccessKeyId(false); + setCopiedSecretKey(false); + + // Fetch the secret key immediately + try { + const secretKey = await accessApi.getSecretKey(key.accessKeyId); + setDetailsSecretKey(secretKey); + } catch (error) { + console.error('Failed to fetch secret key:', error); + } finally { + setIsLoadingDetailsSecretKey(false); + } + }; + return (
Access Key ID Status Created - Last Used Permissions @@ -426,7 +446,7 @@ export function AccessControl() { {isLoading ? ( - +
Loading API keys... @@ -435,24 +455,36 @@ export function AccessControl() { ) : filteredKeys.length === 0 ? ( - + {searchQuery ? 'No keys found matching your search' : 'No API keys yet'} ) : ( filteredKeys.map((key) => ( - + handleRowClick(key)} + className="cursor-pointer hover:bg-muted/50" + > {key.name}
- + { + e.stopPropagation(); + navigator.clipboard.writeText(key.accessKeyId); + toast.success('Access Key ID copied to clipboard'); + }} + > {key.accessKeyId}
-
+
- +
-

+

Important: Save This Key Now

-

+

This is the only time you'll see the secret access key. Make sure to copy and save it securely. If you lose it, you'll need to create a new key.

@@ -794,7 +837,15 @@ export function AccessControl() {
- + { + if (selectedKey?.accessKeyId) { + navigator.clipboard.writeText(selectedKey.accessKeyId); + toast.success('Access Key ID copied to clipboard'); + } + }} + > {selectedKey?.accessKeyId}
-
-
- -
-

- Security Warning -

-

- Keep this secret key secure. Anyone with access to it can perform operations on your behalf. -

-
-
-
- - {/* Current Status Display */} -
-
-
- Current Status: - - {settingsKey?.status} - -
- {settingsKey?.expiration && ( -
- Current Expiration: - - {formatDate(settingsKey.expiration)} - -
- )} -
-
+
+
+ + {/* Secret Access Key */} +
+ +
+ {isLoadingDetailsSecretKey ? ( +
+ + Loading secret key... +
+ ) : ( + <> + { + if (detailsSecretKey) { + navigator.clipboard.writeText(detailsSecretKey); + setCopiedSecretKey(true); + setTimeout(() => setCopiedSecretKey(false), 2000); + toast.success('Secret Access Key copied to clipboard'); + } + }} + > + {'•'.repeat(40)} + + + + )} +
+
+ + {/* Metadata */} +
+
+ +
{viewingKey && formatDate(viewingKey.createdAt)}
+
+ {viewingKey?.expiration && ( +
+ +
{formatDate(viewingKey.expiration)}
+
+ )} +
+ + {/* Bucket Permissions */} +
+ + {viewingKey && viewingKey.permissions.length > 0 ? ( +
+ {viewingKey.permissions.map((perm, idx) => ( +
+
+
{perm.bucketName}
+
+ {formatPermissions(perm)} +
+
+
+ {perm.read && ( + + Read + + )} + {perm.write && ( + + Write + + )} + {perm.owner && ( + + Owner + + )} +
+
+ ))} +
+ ) : ( +
+

+ This key has no bucket permissions yet +

+
+ )} +
+
+ + + + + + + {/* Edit Permissions Dialog */} diff --git a/frontend/src/pages/Buckets.tsx b/frontend/src/pages/Buckets.tsx index 41d7da2..cf4a679 100644 --- a/frontend/src/pages/Buckets.tsx +++ b/frontend/src/pages/Buckets.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Header } from '@/components/layout/header'; -import { useBuckets } from '@/hooks/useBuckets'; +import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi'; import { useBucketObjects } from '@/hooks/useBucketObjects'; import { BucketListView } from '@/components/buckets/BucketListView'; import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView'; @@ -51,7 +51,10 @@ export function Buckets() { }, [searchParams]); // Custom hooks - const { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets(); + const { data: buckets = [], isLoading: bucketsLoading } = useBuckets(); + const createBucketMutation = useCreateBucket(); + const deleteBucketMutation = useDeleteBucket(); + const grantPermissionMutation = useGrantBucketPermission(); const { objects, isLoading: objectsLoading, @@ -62,6 +65,7 @@ export function Buckets() { itemsPerPage, setItemsPerPage, uploadFiles, + uploadTasks, deleteObject, deleteMultipleObjects, createDirectory, @@ -145,6 +149,38 @@ export function Buckets() { } }; + // Wrapper functions for mutations to match dialog APIs + const createBucket = async (name: string, region?: string) => { + try { + await createBucketMutation.mutateAsync({ name, region }); + return true; + } catch (error) { + return false; + } + }; + + const deleteBucket = async (name: string) => { + try { + await deleteBucketMutation.mutateAsync(name); + return true; + } catch (error) { + return false; + } + }; + + const grantPermission = async ( + bucketName: string, + accessKeyId: string, + permissions: { read: boolean; write: boolean; owner: boolean } + ) => { + try { + await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions }); + return true; + } catch (error) { + return false; + } + }; + // If viewing a bucket's objects, show the object browser view if (viewingBucket) { return ( @@ -161,6 +197,7 @@ export function Buckets() { onNavigateToFolder={handleNavigateToFolder} onBackToBuckets={handleBackToBuckets} onUploadFiles={uploadFiles} + uploadTasks={uploadTasks} onDeleteObject={deleteObject} onDeleteMultipleObjects={deleteMultipleObjects} onCreateDirectory={createDirectory} diff --git a/frontend/src/pages/Cluster.tsx b/frontend/src/pages/Cluster.tsx index 68359ef..4a52ce3 100644 --- a/frontend/src/pages/Cluster.tsx +++ b/frontend/src/pages/Cluster.tsx @@ -1,6 +1,6 @@ import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; import {Header} from '@/components/layout/header'; -import {formatBytes} from '@/lib/utils'; +import {formatBytes} from '@/lib/file-utils'; import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react'; import {useQuery} from '@tanstack/react-query'; import {garageApi} from '@/lib/api'; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0772591..acd5730 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; import {Header} from '@/components/layout/header'; -import {formatBytes} from '@/lib/utils'; +import {formatBytes} from '@/lib/file-utils'; import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react'; import {BucketUsageChart} from '@/components/charts/BucketUsageChart'; import {useDashboardData} from '@/hooks/useApi'; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1ce6dba..535bf72 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -49,10 +49,6 @@ export function Login() { return (
-
-

Sign in to Garage UI

-

Enter your credentials to continue

-
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 49f7be6..7f1023c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -33,6 +33,7 @@ export interface S3Object { size: number; lastModified: string; etag?: string; + contentType?: string; storageClass?: string; isFolder?: boolean; } @@ -52,6 +53,7 @@ export interface ObjectMetadata { lastModified: string; contentType: string; etag: string; + storageClass?: string; metadata?: Record; versionId?: string; } @@ -62,7 +64,6 @@ export interface AccessKey { name: string; secretKey?: string; createdAt: string; - lastUsed?: string; status: 'active' | 'inactive'; permissions: BucketPermission[]; expiration?: string; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 0a5249d..92408a9 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -47,6 +47,15 @@ export default { md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', }, + keyframes: { + shimmer: { + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(100%)' }, + }, + }, + animation: { + shimmer: 'shimmer 2s infinite', + }, }, }, plugins: [], diff --git a/helm/garage-ui/templates/configmap.yaml b/helm/garage-ui/templates/configmap.yaml index 4dbfead..bfacfd8 100644 --- a/helm/garage-ui/templates/configmap.yaml +++ b/helm/garage-ui/templates/configmap.yaml @@ -10,4 +10,6 @@ data: {{- $_ := unset $config.garage "admin_token" }} {{- $_2 := unset $config.auth.admin "password" }} {{- $_3 := unset $config.auth.oidc "client_secret" }} +{{- $_4 := unset $config.auth "jwt_private_key" }} +{{- $_5 := unset $config.auth "jwt_private_key_secret" }} {{- $config | toYaml | nindent 4 }} diff --git a/helm/garage-ui/templates/deployment.yaml b/helm/garage-ui/templates/deployment.yaml index 61f92e0..f2f6706 100644 --- a/helm/garage-ui/templates/deployment.yaml +++ b/helm/garage-ui/templates/deployment.yaml @@ -46,6 +46,18 @@ spec: name: {{ include "garage-ui.fullname" . }}-admin-token key: admin-token {{- end }} + {{- if or .Values.config.auth.jwt_private_key .Values.config.auth.jwt_private_key_secret.name }} + - name: GARAGE_UI_AUTH_JWT_PRIVATE_KEY + valueFrom: + secretKeyRef: + {{- if .Values.config.auth.jwt_private_key_secret.name }} + name: {{ .Values.config.auth.jwt_private_key_secret.name }} + key: {{ .Values.config.auth.jwt_private_key_secret.key }} + {{- else }} + name: {{ include "garage-ui.fullname" . }}-jwt-key + key: jwt-key + {{- end }} + {{- end }} {{- if .Values.config.auth.oidc.enabled }} - name: GARAGE_UI_AUTH_OIDC_CLIENT_SECRET valueFrom: diff --git a/helm/garage-ui/templates/secret.yaml b/helm/garage-ui/templates/secret.yaml index 4eaee99..2f2e1cb 100644 --- a/helm/garage-ui/templates/secret.yaml +++ b/helm/garage-ui/templates/secret.yaml @@ -37,3 +37,15 @@ type: Opaque data: client-secret: {{ .Values.config.auth.oidc.client_secret | b64enc | quote }} {{- end }} +--- +{{- if and (not .Values.config.auth.jwt_private_key_secret.name) .Values.config.auth.jwt_private_key }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "garage-ui.fullname" . }}-jwt-key + labels: + {{- include "garage-ui.labels" . | nindent 4 }} +type: Opaque +data: + jwt-key: {{ .Values.config.auth.jwt_private_key | b64enc | quote }} +{{- end }} diff --git a/helm/garage-ui/values.schema.json b/helm/garage-ui/values.schema.json index bfd3131..0b523da 100644 --- a/helm/garage-ui/values.schema.json +++ b/helm/garage-ui/values.schema.json @@ -101,6 +101,30 @@ "description": "Full external URL for OAuth2 redirects (REQUIRED when OIDC is enabled)", "pattern": "^https?://", "default": "https://garage-ui.example.com" + }, + "max_body_size": { + "type": "integer", + "description": "Maximum request body size in bytes (for file uploads)", + "minimum": 1, + "default": 314572800 + }, + "max_header_size": { + "type": "integer", + "description": "Maximum request header size in bytes", + "minimum": 1, + "default": 1048576 + }, + "read_buffer_size": { + "type": "integer", + "description": "Read buffer size for request data in bytes", + "minimum": 1, + "default": 4096 + }, + "write_buffer_size": { + "type": "integer", + "description": "Write buffer size for response data in bytes", + "minimum": 1, + "default": 4096 } } }, @@ -154,6 +178,27 @@ "description": "Authentication configuration (one or both methods can be enabled)", "required": ["admin", "oidc"], "properties": { + "jwt_private_key": { + "type": "string", + "description": "Ed25519 private key for JWT signing in PEM format (EdDSA algorithm). Generate with: openssl genpkey -algorithm ED25519. If not provided, auto-generated on each restart (not recommended for production)", + "default": "" + }, + "jwt_private_key_secret": { + "type": "object", + "description": "Use an existing Kubernetes secret for the JWT private key (recommended)", + "properties": { + "name": { + "type": "string", + "description": "Name of the existing secret containing the JWT private key", + "default": "" + }, + "key": { + "type": "string", + "description": "Key within the secret that contains the JWT private key value", + "default": "jwt-key.pem" + } + } + }, "admin": { "type": "object", "description": "Admin authentication settings (username/password)", diff --git a/helm/garage-ui/values.yaml b/helm/garage-ui/values.yaml index 77f8400..97ba6ba 100644 --- a/helm/garage-ui/values.yaml +++ b/helm/garage-ui/values.yaml @@ -1,610 +1,211 @@ -# Default values for garage-ui Helm chart -# This file contains configuration values for deploying Garage UI -# Customize the values below according to your deployment environment +# Default values for garage-ui -# Number of replica pods to run -# Increase for high availability (recommended: 2-3 for production) replicaCount: 1 -# Docker image configuration image: - # Container registry and image name - # Default uses the official Garage UI image from Docker Hub repository: noooste/garage-ui - - # Image pull policy - # Options: Always, IfNotPresent, Never - # IfNotPresent: Only pull if image is not already present locally pullPolicy: IfNotPresent - - # Image tag to use (defaults to chart appVersion if empty) - # Example: "1.0.0" or "latest" + # Overrides the image tag whose default is the chart appVersion tag: "" -# Credentials for accessing private container registries -# Example: -# imagePullSecrets: -# - name: regcred imagePullSecrets: [] - -# Override the default chart name in resource names -# Leave empty to use the chart name nameOverride: "" - -# Override the full resource name (includes release name) -# Leave empty to use the default naming convention fullnameOverride: "" -# ============================================================================ -# APPLICATION CONFIGURATION -# ============================================================================ -# This section contains the main application configuration -# Customize according to your Garage deployment and security requirements -# -# TIP: You can now easily override specific values using --set: -# --set config.server.port=9090 -# --set config.garage.admin_token=your-token -# --set config.auth.mode=basic config: - # ======================================== - # Server Configuration - # ======================================== server: - # Network interface to bind to - # "0.0.0.0" listens on all interfaces (recommended for containers) host: "0.0.0.0" - - # Port the application listens on - # Default: 8080 port: 8080 - - # Deployment environment - # Options: "production", "development", "staging" environment: "production" - - # Domain name for the application - # Example: "garage-ui.example.com" domain: "garage-ui.example.com" - - # Protocol for internal communication - # Options: "http", "https" - # Keep as "http" for internal Kubernetes communication (TLS termination at ingress) protocol: "http" - - # Full external URL for OAuth2 redirects and external links - # This is the public-facing URL users access - # Example: "https://garage-ui.example.com" - # REQUIRED when OIDC authentication is enabled + # Full external URL (required for OIDC) root_url: "https://garage-ui.example.com" + # Request size limits (in bytes) + max_body_size: 314572800 # 300MB + max_header_size: 1048576 # 1MB + read_buffer_size: 4096 # 4KB + write_buffer_size: 4096 # 4KB - # ======================================== - # Garage S3 Storage Configuration - # ======================================== garage: # Garage S3 API endpoint - # Format: http(s)://hostname:port - # Default port: 3900 - # Example: "http://garage.example.com:3900" or "http://garage:3900" for in-cluster endpoint: "http://garage:3900" - - # S3 region name - # For Garage, this can be any arbitrary value (Garage ignores regions) - # Default: "garage" region: "garage" - # Garage Admin API endpoint - # This is used for administrative operations like bucket and key management - # Default port: 3903 - # Example: "http://garage.example.com:3903" or "http://garage:3903" for in-cluster admin_endpoint: "http://garage:3903" - # Admin API bearer token - # REQUIRED: Obtain this from your Garage server configuration - # This token grants administrative access - keep it secure! - # To generate: See Garage documentation for admin token setup - # NOTE: If existingSecret is configured, this value will be ignored admin_token: "" - - # Use an existing Kubernetes secret for the admin token (recommended for production) - # When configured, this takes precedence over the admin_token value above - # The secret should contain a key with the admin token value + # Use existing secret for admin token (recommended) existingSecret: - # Name of the existing secret containing the admin token - # Leave empty to use the admin_token value above instead - # Example: "garage-admin-token" name: "" - - # Key within the secret that contains the admin token value - # Default: "admin-token" key: "admin-token" - # ======================================== - # Authentication Configuration - # ======================================== - # You can enable one or both authentication methods auth: - # Admin Authentication Settings - # Provides simple username/password protection + # Ed25519 private key for JWT signing (PEM format) + # Generate with: openssl genpkey -algorithm ED25519 + # If not provided, auto-generated on each restart (not recommended for production) + jwt_private_key: "" + # Use existing secret for JWT private key (recommended) + jwt_private_key_secret: + name: "" + key: "jwt-key.pem" + + # Admin authentication (username/password) admin: - # Enable or disable admin authentication - # Set to true to enable username/password login enabled: false - - # Username for admin login username: "admin" - - # Password for admin login - # NOTE: If existingSecret is configured, this value will be ignored - # Leave empty to auto-generate a random password (when existingSecret is not specified) password: "changeme" - - # Use an existing Kubernetes secret for the admin password (recommended for production) - # When configured, this takes precedence over the password value above - # The secret should contain a key with the admin password value existingSecret: - # Name of the existing secret containing the admin password - # Leave empty to use the password value above or auto-generate - # Example: "admin-credentials" name: "" - - # Key within the secret that contains the admin password value - # Default: "admin-password" key: "admin-password" - # OpenID Connect (OIDC) Configuration - # Integrates with identity providers like Keycloak, Auth0, Okta, etc. - # NOTE: When OIDC is enabled, server.root_url must be configured - # The OAuth2 redirect URL is automatically constructed as: {root_url}/auth/oidc/callback - # Make sure to register this callback URL in your OIDC provider settings + # OIDC authentication (Keycloak, Auth0, Okta, etc.) + # NOTE: Requires server.root_url to be set oidc: - # Enable or disable OIDC authentication - # Set to true to enable OIDC login enabled: false - - # Display name of your OIDC provider - # Examples: "Keycloak", "Auth0", "Okta", "Azure AD" provider_name: "Keycloak" - - # OAuth2 client ID registered with your OIDC provider - # Obtain this from your OIDC provider's application settings client_id: "garage-ui" - - # OAuth2 client secret registered with your OIDC provider - # IMPORTANT: Keep this secret secure! Consider using Kubernetes secrets - # NOTE: If existingSecret is configured, this value will be ignored client_secret: "your-client-secret" - - # Use an existing Kubernetes secret for the client secret (recommended for production) - # When configured, this takes precedence over the client_secret value above - # The secret should contain a key with the client secret value existingSecret: - # Name of the existing secret containing the client secret - # Leave empty to use the client_secret value above instead - # Example: "oidc-client-secret" name: "" - - # Key within the secret that contains the client secret value - # Default: "client-secret" key: "client-secret" - - # OAuth2/OIDC scopes to request during authentication - # Standard scopes: openid (required), email, profile - # Add custom scopes as needed by your provider scopes: - openid - email - profile - - # OIDC Provider Endpoints - # Replace "keycloak.example.com" and realm with your actual values - # For Keycloak: https://your-keycloak/realms/your-realm - # For Auth0: https://your-tenant.auth0.com - # For Okta: https://your-domain.okta.com - - # OIDC issuer URL (base URL for OIDC discovery) + # OIDC provider endpoints issuer_url: "https://keycloak.example.com/realms/master" - - # Authorization endpoint URL auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth" - - # Token endpoint URL token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token" - - # User info endpoint URL userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo" - - # Token Validation Settings - # Set to true only if you have issuer mismatch issues (not recommended) + # Validation settings skip_issuer_check: false - - # Set to true to skip token expiry validation (not recommended for production) skip_expiry_check: false - - # User Attribute Mappings - # Map OIDC claims to application user attributes - # Adjust these based on your OIDC provider's claim structure - - # Claim containing user's email address + # User attribute mappings email_attribute: "email" - - # Claim containing user's username/login ID username_attribute: "preferred_username" - - # Claim containing user's display name name_attribute: "name" - - # Role-Based Access Control (Optional) - # Path to roles in the OIDC token claims - # Example for Keycloak: "resource_access.garage-ui.roles" - # Example for Auth0: "https://your-app/roles" + # Role-based access control role_attribute_path: "resource_access.garage-ui.roles" - - # Role name that grants admin privileges - # Users with this role will have full administrative access admin_role: "admin" - - # TLS/SSL Configuration - # Skip TLS certificate verification (only for testing - never in production!) + # TLS settings tls_skip_verify: false - - # Session Management - # How long user sessions remain valid without activity - # Value in seconds (86400 = 24 hours) - session_max_age: 86400 - - # Name of the session cookie + # Session settings + session_max_age: 86400 # 24 hours cookie_name: "garage_session" - - # Only send cookie over HTTPS connections - # Set to false only for local development without HTTPS cookie_secure: true - - # Prevent JavaScript access to the cookie (security feature) - # Should remain true for security cookie_http_only: true - - # SameSite cookie attribute for CSRF protection - # Options: "lax" (recommended), "strict", "none" - # Use "lax" for most cases, "strict" for maximum security cookie_same_site: "lax" - # ======================================== - # CORS (Cross-Origin Resource Sharing) - # ======================================== - # Configure which origins can access the API from browsers + # CORS configuration cors: - # Enable or disable CORS - # Disable if the frontend and backend are served from the same origin enabled: true - - # List of allowed origins - # "*" allows all origins (convenient but less secure) - # For production, specify exact origins: - # Example: - # - "https://garage-ui.example.com" - # - "https://app.example.com" allowed_origins: - "*" - - # HTTP methods that are allowed in CORS requests - # Include all methods your API uses allowed_methods: - GET - POST - PUT - DELETE - OPTIONS - - # HTTP headers that are allowed in CORS requests - # Add any custom headers your application requires allowed_headers: - Origin - Content-Type - Accept - Authorization - - # Allow credentials (cookies, authorization headers) in CORS requests - # Set to true if your frontend needs to send authentication cookies - # NOTE: When true, allowed_origins cannot be "*" allow_credentials: false - - # How long browsers can cache CORS preflight responses (in seconds) - # 3600 = 1 hour max_age: 3600 - # ======================================== - # Logging Configuration - # ======================================== logging: - # Log verbosity level - # Options: - # "debug" - Verbose logging, useful for troubleshooting - # "info" - Standard logging (recommended for production) - # "warn" - Only warnings and errors - # "error" - Only errors + # Options: debug, info, warn, error level: "info" - - # Log output format - # Options: - # "json" - Structured JSON format (recommended for production/log aggregation) - # "text" - Human-readable text format (useful for development) + # Options: json, text format: "json" -# ============================================================================ -# KUBERNETES POD CONFIGURATION -# ============================================================================ - -# Annotations to add to the pod -# Use for integrations with service meshes, monitoring, etc. -# Example: -# podAnnotations: -# prometheus.io/scrape: "true" -# prometheus.io/port: "8080" +# Pod annotations podAnnotations: {} -# Pod-level security context -# Defines security settings for all containers in the pod +# Pod security context podSecurityContext: - # Run containers as non-root user (security best practice) runAsNonRoot: true - - # User ID to run containers as - # Default: 1000 (non-privileged user) runAsUser: 1000 - - # Group ID for filesystem access - # Files created by the pod will have this group ownership fsGroup: 1000 -# Container-level security context -# Security settings specific to the application container +# Container security context securityContext: - # Prevent privilege escalation (security best practice) - # Ensures the container cannot gain more privileges than its parent allowPrivilegeEscalation: false - - # Drop all Linux capabilities and only add what's needed - # This follows the principle of least privilege capabilities: drop: - ALL - - # Allow write access to the root filesystem - # Set to true for read-only root filesystem (more secure but may require adjustments) readOnlyRootFilesystem: false -# ============================================================================ -# KUBERNETES SERVICE -# ============================================================================ service: - # Service type determines how the service is exposed - # Options: - # ClusterIP - Internal only (default, recommended when using Ingress) - # NodePort - Expose on each node's IP at a static port - # LoadBalancer - Expose using a cloud provider's load balancer type: ClusterIP - - # Port the service listens on - # This is the port other services/ingress will connect to - # Default: 80 port: 80 -# ============================================================================ -# INGRESS CONFIGURATION -# ============================================================================ -# Ingress exposes HTTP/HTTPS routes from outside the cluster to the service ingress: - # Enable or disable ingress - # Set to true to make the application accessible from outside the cluster enabled: false - - # Ingress class name - # Specifies which ingress controller to use - # Common values: "nginx", "traefik", "alb" (AWS) - # Ensure the specified controller is installed in your cluster className: "nginx" - - # Additional annotations for the ingress - # Use for configuring ingress controller behavior, SSL, authentication, etc. - # Examples: - # cert-manager.io/cluster-issuer: "letsencrypt-prod" # For automatic SSL certificates - # nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - # nginx.ingress.kubernetes.io/proxy-body-size: "100m" annotations: {} - - # Hostname and path configuration + # cert-manager.io/cluster-issuer: "letsencrypt-prod" + # nginx.ingress.kubernetes.io/force-ssl-redirect: "true" hosts: - # Replace "garage-ui.local" with your actual domain - host: garage-ui.local paths: - # Path where the application will be accessible - path: / - # pathType options: Prefix, Exact, ImplementationSpecific pathType: Prefix - - # TLS/SSL configuration - # Uncomment and configure to enable HTTPS - # Requires a TLS certificate stored in a Kubernetes secret tls: [] # - secretName: garage-ui-tls # hosts: # - garage-ui.local - # - # To use cert-manager for automatic certificates: - # 1. Install cert-manager in your cluster - # 2. Create a ClusterIssuer (e.g., letsencrypt-prod) - # 3. Add annotation: cert-manager.io/cluster-issuer: "letsencrypt-prod" - # 4. Uncomment the tls section above -# ============================================================================ -# RESOURCE LIMITS AND REQUESTS -# ============================================================================ -# Configure CPU and memory allocation for the pods -# Requests: Guaranteed resources (used for scheduling) -# Limits: Maximum resources the container can use resources: limits: - # Maximum CPU cores the container can use - # 500m = 0.5 CPU cores - # Increase for high-traffic deployments cpu: 500m - - # Maximum memory the container can use - # Container will be killed if it exceeds this limit memory: 512Mi - requests: - # Guaranteed CPU allocated to the container - # 100m = 0.1 CPU cores - # Cluster scheduler ensures this amount is available cpu: 100m - - # Guaranteed memory allocated to the container - # Cluster scheduler ensures this amount is available memory: 128Mi -# ============================================================================ -# HEALTH CHECKS -# ============================================================================ - -# Liveness Probe -# Kubernetes restarts the container if this probe fails -# Detects if the application is running but in a broken state livenessProbe: - # Enable or disable the liveness probe enabled: true - - # HTTP endpoint to check httpGet: - # Health check endpoint path path: /health - # Port name (defined in the container spec) port: http - - # Wait time before starting liveness checks after container starts - # Give the application time to initialize initialDelaySeconds: 30 - - # How often to perform the probe (in seconds) periodSeconds: 10 - - # Maximum time to wait for the probe to complete timeoutSeconds: 3 - - # Number of consecutive failures before restarting the container failureThreshold: 3 -# Readiness Probe -# Kubernetes stops sending traffic if this probe fails -# Determines when the container is ready to accept traffic readinessProbe: - # Enable or disable the readiness probe enabled: true - - # HTTP endpoint to check httpGet: - # Readiness check endpoint path path: /health - # Port name (defined in the container spec) port: http - - # Wait time before starting readiness checks after container starts initialDelaySeconds: 10 - - # How often to perform the probe (in seconds) periodSeconds: 5 - - # Maximum time to wait for the probe to complete timeoutSeconds: 3 - - # Number of consecutive failures before marking as not ready failureThreshold: 3 -# ============================================================================ -# MONITORING AND OBSERVABILITY -# ============================================================================ - # ServiceMonitor for Prometheus Operator -# Automatically configure Prometheus to scrape metrics from the application -# Requires Prometheus Operator to be installed in the cluster serviceMonitor: - # Enable or disable ServiceMonitor creation - # Set to true if using Prometheus Operator for monitoring enabled: false - - # How often Prometheus should scrape metrics - # Format: duration string (e.g., "30s", "1m", "5m") interval: 30s - - # Metrics endpoint path - # The application exposes metrics at this path path: /api/v1/monitoring/metrics - - # Additional labels for the ServiceMonitor - # Use to match Prometheus scrape configurations - # Example: - # labels: - # prometheus: kube-prometheus labels: {} -# ============================================================================ -# NETWORK POLICY -# ============================================================================ -# Controls network traffic to/from the pods -# Requires a network plugin that supports NetworkPolicy (e.g., Calico, Cilium) +# NetworkPolicy networkPolicy: - # Enable or disable NetworkPolicy creation - # Provides network-level security by restricting pod communication enabled: false - - # Types of policies to enforce - # Ingress: Controls incoming traffic to the pod - # Egress: Controls outgoing traffic from the pod policyTypes: - Ingress - Egress - # Note: When enabled, by default only allows necessary traffic - # Customize the NetworkPolicy template if you need specific rules - -# ============================================================================ -# POD SCHEDULING -# ============================================================================ - -# Node Selector -# Schedule pods only on nodes with specific labels -# Useful for deploying to specific node pools or hardware types -# Example: -# nodeSelector: -# disktype: ssd -# environment: production +# Node labels for pod assignment nodeSelector: {} -# Tolerations -# Allow pods to be scheduled on nodes with matching taints -# Useful for dedicated node pools or special hardware -# Example: -# tolerations: -# - key: "dedicated" -# operator: "Equal" -# value: "garage-ui" -# effect: "NoSchedule" +# Tolerations for pod assignment tolerations: [] -# Affinity Rules -# Advanced pod scheduling constraints -# Controls which nodes pods can be scheduled on and pod co-location -# Example for pod anti-affinity (spread pods across nodes): -# affinity: -# podAntiAffinity: -# preferredDuringSchedulingIgnoredDuringExecution: -# - weight: 100 -# podAffinityTerm: -# labelSelector: -# matchExpressions: -# - key: app.kubernetes.io/name -# operator: In -# values: -# - garage-ui -# topologyKey: kubernetes.io/hostname +# Affinity for pod assignment affinity: {}