From 5d1a64f1220d715e74d913c6c2058fb0b35f4931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Can=20Erdog=CC=86an?= Date: Mon, 26 Jan 2026 18:25:03 +0300 Subject: [PATCH 1/3] Add unified multi-service support API - Add new unified /services endpoints for managing Redis, PostgreSQL, Vector DB, and Storage services - Implement service model, repository, and service layer for multi-service architecture - Add database migrations for multi-service support (002_multi_service_support) - Add service types, engines, and regions endpoints for service discovery - Improve error logging in handlers with internal error details - Update logo SVG to use transparent background with proper viewBox - Mount dashboard public directory in Docker Compose for hot reload - Update favicon reference in dashboard Co-Authored-By: Claude Opus 4.5 --- docker-compose.yml | 1 + services/api/cmd/api/main.go | 24 +- services/api/internal/handlers/common.go | 6 + services/api/internal/handlers/services.go | 264 +++++++++++ services/api/internal/models/service.go | 242 ++++++++++ services/api/internal/repository/service.go | 441 ++++++++++++++++++ .../api/internal/services/service_service.go | 350 ++++++++++++++ .../002_multi_service_support.down.sql | 17 + .../002_multi_service_support.up.sql | 236 ++++++++++ services/dashboard/index.html | 2 +- services/dashboard/public/logo.svg | 2 +- services/dashboard/src/assets/logo.svg | 2 +- 12 files changed, 1583 insertions(+), 4 deletions(-) create mode 100644 services/api/internal/handlers/services.go create mode 100644 services/api/internal/models/service.go create mode 100644 services/api/internal/repository/service.go create mode 100644 services/api/internal/services/service_service.go create mode 100644 services/api/migrations/002_multi_service_support.down.sql create mode 100644 services/api/migrations/002_multi_service_support.up.sql diff --git a/docker-compose.yml b/docker-compose.yml index 1dbdda0..0d79fe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,6 +137,7 @@ services: - "3000:3000" volumes: - ./services/dashboard/src:/app/src:ro + - ./services/dashboard/public:/app/public:ro depends_on: - api networks: diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 434d10e..f875ca9 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -53,12 +53,15 @@ func main() { orgRepo := repository.NewOrganizationRepository(db) dbRepo := repository.NewDatabaseRepository(db) apiKeyRepo := repository.NewApiKeyRepository(db) + svcRepo := repository.NewServiceRepository(db) + engineRepo := repository.NewAvailableEngineRepository(db) // Initialize services jwtManager := auth.NewJWTManager(cfg) authService := services.NewAuthService(userRepo, orgRepo, jwtManager) dbService := services.NewDatabaseService(dbRepo, orgRepo, redisPool, cfg) userService := services.NewUserService(userRepo, apiKeyRepo) + svcService := services.NewServiceService(svcRepo, engineRepo, orgRepo, cfg) // Initialize handlers authHandler := handlers.NewAuthHandler(authService) @@ -66,13 +69,14 @@ func main() { redisProxyHandler := handlers.NewRedisProxyHandler(dbService, redisPool) healthHandler := handlers.NewHealthHandler() userHandler := handlers.NewUserHandler(userService) + serviceHandler := handlers.NewServiceHandler(svcService) // Initialize rate limiters rateLimiter := middleware.NewRateLimiter(cfg.RateLimitRequests, cfg.RateLimitWindow) authRateLimiter := middleware.NewRateLimiter(cfg.AuthRateLimitRequests, cfg.AuthRateLimitWindow) // Setup router - router := setupRouter(cfg, jwtManager, rateLimiter, authRateLimiter, authHandler, dbHandler, redisProxyHandler, healthHandler, userHandler) + router := setupRouter(cfg, jwtManager, rateLimiter, authRateLimiter, authHandler, dbHandler, redisProxyHandler, healthHandler, userHandler, serviceHandler) // Start server srv := &http.Server{ @@ -159,6 +163,7 @@ func setupRouter( redisProxyHandler *handlers.RedisProxyHandler, healthHandler *handlers.HealthHandler, userHandler *handlers.UserHandler, + serviceHandler *handlers.ServiceHandler, ) *gin.Engine { if cfg.IsProduction() { gin.SetMode(gin.ReleaseMode) @@ -229,6 +234,23 @@ func setupRouter( tokenAPI.POST("/databases/:id", redisProxyHandler.UpstashCommand) } + // Unified services endpoints (authenticated) + svcs := v1.Group("/services") + svcs.Use(middleware.AuthMiddleware(jwtManager)) + { + // Public info endpoints (no auth needed, but keeping under /services for consistency) + svcs.GET("/types", serviceHandler.GetServiceTypes) + svcs.GET("/engines", serviceHandler.GetAvailableEngines) + svcs.GET("/regions", serviceHandler.GetRegions) + + // Service CRUD + svcs.POST("", serviceHandler.Create) + svcs.GET("", serviceHandler.List) + svcs.GET("/:id", serviceHandler.Get) + svcs.DELETE("/:id", serviceHandler.Delete) + svcs.POST("/:id/reset-credentials", serviceHandler.ResetCredentials) + } + return router } diff --git a/services/api/internal/handlers/common.go b/services/api/internal/handlers/common.go index eb0a3d3..e37e04b 100644 --- a/services/api/internal/handlers/common.go +++ b/services/api/internal/handlers/common.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/lazycache-com/lazycache/internal/errors" + "github.com/rs/zerolog/log" ) // ErrorResponse represents an error response @@ -18,6 +19,10 @@ type ErrorResponse struct { // respondError sends an error response func respondError(c *gin.Context, err error) { if appErr, ok := err.(*errors.AppError); ok { + // Log internal error for debugging + if appErr.Internal != nil { + log.Error().Err(appErr.Internal).Str("code", appErr.Code).Msg("Application error with internal cause") + } c.JSON(appErr.StatusCode, ErrorResponse{ Error: struct { Code string `json:"code"` @@ -30,6 +35,7 @@ func respondError(c *gin.Context, err error) { return } + log.Error().Err(err).Msg("Unhandled error") c.JSON(http.StatusInternalServerError, ErrorResponse{ Error: struct { Code string `json:"code"` diff --git a/services/api/internal/handlers/services.go b/services/api/internal/handlers/services.go new file mode 100644 index 0000000..5e04817 --- /dev/null +++ b/services/api/internal/handlers/services.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/lazycache-com/lazycache/internal/errors" + "github.com/lazycache-com/lazycache/internal/middleware" + "github.com/lazycache-com/lazycache/internal/models" + "github.com/lazycache-com/lazycache/internal/services" +) + +// ServiceHandler handles service management endpoints +type ServiceHandler struct { + svcService *services.ServiceService +} + +// NewServiceHandler creates a new service handler +func NewServiceHandler(svcService *services.ServiceService) *ServiceHandler { + return &ServiceHandler{svcService: svcService} +} + +// Create godoc +// @Summary Create a new service +// @Tags services +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body models.CreateServiceRequest true "Create service request" +// @Success 201 {object} models.ServiceResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 402 {object} ErrorResponse +// @Router /services [post] +func (h *ServiceHandler) Create(c *gin.Context) { + var req models.CreateServiceRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, errors.NewValidationError(err.Error())) + return + } + + // Validate engine is valid for service type + if !models.ValidateEngine(req.ServiceType, req.Engine) { + respondError(c, errors.NewValidationError("Invalid engine for service type")) + return + } + + orgID := middleware.GetOrganizationID(c) + + response, err := h.svcService.Create(c.Request.Context(), orgID, &req) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusCreated, response) +} + +// List godoc +// @Summary List all services +// @Tags services +// @Produce json +// @Security BearerAuth +// @Param type query string false "Filter by service type (cache, database, vector, storage)" +// @Success 200 {object} map[string][]models.ServiceResponse +// @Failure 401 {object} ErrorResponse +// @Router /services [get] +func (h *ServiceHandler) List(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + + // Optional filter by service type + serviceTypeStr := c.Query("type") + var serviceType *models.ServiceType + if serviceTypeStr != "" { + st := models.ServiceType(serviceTypeStr) + serviceType = &st + } + + svcs, err := h.svcService.List(c.Request.Context(), orgID, serviceType) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "services": svcs, + }) +} + +// Get godoc +// @Summary Get service by ID +// @Tags services +// @Produce json +// @Security BearerAuth +// @Param id path string true "Service ID" +// @Success 200 {object} models.ServiceResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /services/{id} [get] +func (h *ServiceHandler) Get(c *gin.Context) { + svcID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, errors.NewValidationError("Invalid service ID")) + return + } + + orgID := middleware.GetOrganizationID(c) + + svc, err := h.svcService.Get(c.Request.Context(), orgID, svcID) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, svc) +} + +// Delete godoc +// @Summary Delete a service +// @Tags services +// @Security BearerAuth +// @Param id path string true "Service ID" +// @Success 204 "No Content" +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /services/{id} [delete] +func (h *ServiceHandler) Delete(c *gin.Context) { + svcID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, errors.NewValidationError("Invalid service ID")) + return + } + + orgID := middleware.GetOrganizationID(c) + + if err := h.svcService.Delete(c.Request.Context(), orgID, svcID); err != nil { + respondError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +// ResetCredentials godoc +// @Summary Reset service credentials +// @Tags services +// @Produce json +// @Security BearerAuth +// @Param id path string true "Service ID" +// @Success 200 {object} models.ServiceResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /services/{id}/reset-credentials [post] +func (h *ServiceHandler) ResetCredentials(c *gin.Context) { + svcID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, errors.NewValidationError("Invalid service ID")) + return + } + + orgID := middleware.GetOrganizationID(c) + + svc, err := h.svcService.ResetCredentials(c.Request.Context(), orgID, svcID) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, svc) +} + +// GetAvailableEngines godoc +// @Summary Get available engines +// @Tags services +// @Produce json +// @Param type query string false "Filter by service type (cache, database, vector, storage)" +// @Success 200 {object} map[string][]models.AvailableEngine +// @Router /services/engines [get] +func (h *ServiceHandler) GetAvailableEngines(c *gin.Context) { + serviceTypeStr := c.Query("type") + var serviceType *models.ServiceType + if serviceTypeStr != "" { + st := models.ServiceType(serviceTypeStr) + serviceType = &st + } + + engines, err := h.svcService.GetAvailableEngines(c.Request.Context(), serviceType) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "engines": engines, + }) +} + +// GetRegions godoc +// @Summary Get available regions for an engine +// @Tags services +// @Produce json +// @Param type query string true "Service type (cache, database, vector, storage)" +// @Param engine query string true "Engine (redis, valkey, postgresql, pgvector, s3, minio)" +// @Success 200 {object} map[string][]string +// @Router /services/regions [get] +func (h *ServiceHandler) GetRegions(c *gin.Context) { + serviceType := models.ServiceType(c.Query("type")) + engine := models.ServiceEngine(c.Query("engine")) + + if serviceType == "" || engine == "" { + respondError(c, errors.NewValidationError("Service type and engine are required")) + return + } + + regions, err := h.svcService.GetRegions(c.Request.Context(), serviceType, engine) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "regions": regions, + }) +} + +// GetServiceTypes godoc +// @Summary Get available service types +// @Tags services +// @Produce json +// @Success 200 {object} map[string][]ServiceTypeInfo +// @Router /services/types [get] +func (h *ServiceHandler) GetServiceTypes(c *gin.Context) { + types := []map[string]interface{}{ + { + "type": models.ServiceTypeCache, + "name": "Cache", + "description": "In-memory key-value data store for caching and real-time data", + "engines": []string{"redis", "valkey"}, + }, + { + "type": models.ServiceTypeDatabase, + "name": "Database", + "description": "Relational database for structured data storage", + "engines": []string{"postgresql"}, + }, + { + "type": models.ServiceTypeVector, + "name": "Vector", + "description": "Vector database for AI/ML embeddings and similarity search", + "engines": []string{"pgvector"}, + }, + { + "type": models.ServiceTypeStorage, + "name": "Storage", + "description": "Object storage for files, backups, and media", + "engines": []string{"s3", "minio"}, + }, + } + + c.JSON(http.StatusOK, gin.H{ + "types": types, + }) +} diff --git a/services/api/internal/models/service.go b/services/api/internal/models/service.go new file mode 100644 index 0000000..b429fa1 --- /dev/null +++ b/services/api/internal/models/service.go @@ -0,0 +1,242 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// ServiceType represents the type of service +type ServiceType string + +const ( + ServiceTypeCache ServiceType = "cache" + ServiceTypeDatabase ServiceType = "database" + ServiceTypeVector ServiceType = "vector" + ServiceTypeStorage ServiceType = "storage" +) + +// ServiceEngine represents the specific engine implementation +type ServiceEngine string + +const ( + // Cache engines + EngineRedis ServiceEngine = "redis" + EngineValKey ServiceEngine = "valkey" + + // Database engines + EnginePostgreSQL ServiceEngine = "postgresql" + + // Vector engines + EnginePgVector ServiceEngine = "pgvector" + + // Storage engines + EngineS3 ServiceEngine = "s3" + EngineMinio ServiceEngine = "minio" +) + +// ServiceStatus represents the current status of a service +type ServiceStatus string + +const ( + ServiceStatusCreating ServiceStatus = "creating" + ServiceStatusActive ServiceStatus = "active" + ServiceStatusPaused ServiceStatus = "paused" + ServiceStatusDeleting ServiceStatus = "deleting" + ServiceStatusError ServiceStatus = "error" +) + +// Service represents a unified service instance +type Service struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Name string `json:"name"` + ServiceType ServiceType `json:"service_type"` + Engine ServiceEngine `json:"engine"` + Region string `json:"region"` + + // Connection info + Endpoint string `json:"endpoint"` + Port int `json:"port"` + + // Authentication + Token string `json:"token,omitempty"` // REST API token + Credentials json.RawMessage `json:"-"` // Encrypted credentials (password, keys, etc.) + + // Configuration + Config json.RawMessage `json:"config,omitempty"` + + // Limits & Tier + Tier string `json:"tier"` + Limits json.RawMessage `json:"limits,omitempty"` + + // Status + Status ServiceStatus `json:"status"` + + // Cloud provider reference + ProviderResourceID string `json:"provider_resource_id,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + + // Timestamps + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ServiceCredentials holds the decrypted credentials for a service +type ServiceCredentials struct { + Password string `json:"password,omitempty"` + KeyPrefix string `json:"key_prefix,omitempty"` + Username string `json:"username,omitempty"` + AccessKey string `json:"access_key,omitempty"` + SecretKey string `json:"secret_key,omitempty"` +} + +// CacheConfig holds configuration for cache services (Redis/ValKey) +type CacheConfig struct { + MaxMemoryMB int `json:"max_memory_mb"` + MaxConnections int `json:"max_connections"` + MaxMemoryPolicy string `json:"maxmemory_policy,omitempty"` + AppendOnly bool `json:"appendonly,omitempty"` +} + +// DatabaseConfig holds configuration for database services (PostgreSQL) +type DatabaseConfig struct { + MaxConnections int `json:"max_connections"` + MaxStorageGB int `json:"max_storage_gb"` + SharedBuffers string `json:"shared_buffers,omitempty"` + DatabaseName string `json:"database_name,omitempty"` +} + +// VectorConfig holds configuration for vector services (pgvector) +type VectorConfig struct { + MaxVectors int `json:"max_vectors"` + MaxDimensions int `json:"max_dimensions"` + IVFFlatLists int `json:"ivfflat_lists,omitempty"` + HNSWM int `json:"hnsw_m,omitempty"` + HNSWEfConstruction int `json:"hnsw_ef_construction,omitempty"` +} + +// StorageConfig holds configuration for storage services (S3/MinIO) +type StorageConfig struct { + MaxStorageGB int `json:"max_storage_gb"` + MaxObjects int `json:"max_objects"` + Versioning bool `json:"versioning,omitempty"` + Encryption string `json:"encryption,omitempty"` + BucketName string `json:"bucket_name,omitempty"` +} + +// ServiceMetrics tracks service usage +type ServiceMetrics struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + Date time.Time `json:"date"` + Hour int `json:"hour"` + RequestCount int64 `json:"requests_count"` + StorageBytes int64 `json:"storage_bytes"` + BandwidthIn int64 `json:"bandwidth_in"` + BandwidthOut int64 `json:"bandwidth_out"` + Metrics json.RawMessage `json:"metrics,omitempty"` // Engine-specific metrics + CreatedAt time.Time `json:"created_at"` +} + +// AvailableEngine represents an available engine option +type AvailableEngine struct { + ID uuid.UUID `json:"id"` + ServiceType ServiceType `json:"service_type"` + Engine ServiceEngine `json:"engine"` + + // Display info + DisplayName string `json:"display_name"` + Description *string `json:"description,omitempty"` + IconURL *string `json:"icon_url,omitempty"` + + // Availability + IsActive bool `json:"is_active"` + Regions []string `json:"regions"` + + // Pricing + BasePriceMonthly float64 `json:"base_price_monthly"` + PricePerRequest float64 `json:"price_per_request"` + PricePerGBStorage float64 `json:"price_per_gb_storage"` + PricePerGBBandwidth float64 `json:"price_per_gb_bandwidth"` + + // Limits + FreeTierLimits json.RawMessage `json:"free_tier_limits,omitempty"` + + // Default config + DefaultConfig json.RawMessage `json:"default_config,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateServiceRequest represents a request to create a new service +type CreateServiceRequest struct { + Name string `json:"name" binding:"required,min=3,max=50"` + ServiceType ServiceType `json:"service_type" binding:"required"` + Engine ServiceEngine `json:"engine" binding:"required"` + Region string `json:"region" binding:"required"` + Config json.RawMessage `json:"config,omitempty"` +} + +// ServiceResponse is the API response for a service +type ServiceResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + ServiceType ServiceType `json:"service_type"` + Engine ServiceEngine `json:"engine"` + Region string `json:"region"` + Endpoint string `json:"endpoint"` + Port int `json:"port"` + Status ServiceStatus `json:"status"` + Tier string `json:"tier"` + Config json.RawMessage `json:"config,omitempty"` + Limits json.RawMessage `json:"limits,omitempty"` + CreatedAt time.Time `json:"created_at"` + + // REST API access + RESTEndpoint string `json:"rest_endpoint,omitempty"` + Token string `json:"token,omitempty"` // Only shown on creation +} + +// GetEnginesByServiceType returns available engines for a service type +func GetEnginesByServiceType(st ServiceType) []ServiceEngine { + switch st { + case ServiceTypeCache: + return []ServiceEngine{EngineRedis, EngineValKey} + case ServiceTypeDatabase: + return []ServiceEngine{EnginePostgreSQL} + case ServiceTypeVector: + return []ServiceEngine{EnginePgVector} + case ServiceTypeStorage: + return []ServiceEngine{EngineS3, EngineMinio} + default: + return nil + } +} + +// ValidateEngine checks if the engine is valid for the service type +func ValidateEngine(st ServiceType, engine ServiceEngine) bool { + engines := GetEnginesByServiceType(st) + for _, e := range engines { + if e == engine { + return true + } + } + return false +} + +// GetDefaultPort returns the default port for an engine +func GetDefaultPort(engine ServiceEngine) int { + switch engine { + case EngineRedis, EngineValKey: + return 6379 + case EnginePostgreSQL, EnginePgVector: + return 5432 + case EngineS3, EngineMinio: + return 443 + default: + return 0 + } +} diff --git a/services/api/internal/repository/service.go b/services/api/internal/repository/service.go new file mode 100644 index 0000000..c92183c --- /dev/null +++ b/services/api/internal/repository/service.go @@ -0,0 +1,441 @@ +package repository + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/lazycache-com/lazycache/internal/errors" + "github.com/lazycache-com/lazycache/internal/models" +) + +// ServiceRepository handles service operations +type ServiceRepository struct { + db *pgxpool.Pool +} + +// NewServiceRepository creates a new service repository +func NewServiceRepository(db *pgxpool.Pool) *ServiceRepository { + return &ServiceRepository{db: db} +} + +// Create creates a new service record +func (r *ServiceRepository) Create(ctx context.Context, service *models.Service) error { + query := ` + INSERT INTO services + (id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), NOW()) + ` + + _, err := r.db.Exec(ctx, query, + service.ID, + service.OrganizationID, + service.Name, + service.ServiceType, + service.Engine, + service.Region, + service.Endpoint, + service.Port, + service.Token, + service.Credentials, + service.Config, + service.Tier, + service.Limits, + service.Status, + service.ProviderResourceID, + service.ProviderMetadata, + ) + + if err != nil { + return errors.NewDatabaseError(err) + } + + return nil +} + +// GetByID retrieves a service by ID +func (r *ServiceRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Service, error) { + query := ` + SELECT id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at + FROM services WHERE id = $1 AND status != 'deleting' + ` + + svc := &models.Service{} + err := r.db.QueryRow(ctx, query, id).Scan( + &svc.ID, + &svc.OrganizationID, + &svc.Name, + &svc.ServiceType, + &svc.Engine, + &svc.Region, + &svc.Endpoint, + &svc.Port, + &svc.Token, + &svc.Credentials, + &svc.Config, + &svc.Tier, + &svc.Limits, + &svc.Status, + &svc.ProviderResourceID, + &svc.ProviderMetadata, + &svc.CreatedAt, + &svc.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, errors.NewNotFoundError("Service") + } + if err != nil { + return nil, errors.NewDatabaseError(err) + } + + return svc, nil +} + +// GetByToken retrieves a service by REST API token +func (r *ServiceRepository) GetByToken(ctx context.Context, token string) (*models.Service, error) { + query := ` + SELECT id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at + FROM services WHERE token = $1 AND status = 'active' + ` + + svc := &models.Service{} + err := r.db.QueryRow(ctx, query, token).Scan( + &svc.ID, + &svc.OrganizationID, + &svc.Name, + &svc.ServiceType, + &svc.Engine, + &svc.Region, + &svc.Endpoint, + &svc.Port, + &svc.Token, + &svc.Credentials, + &svc.Config, + &svc.Tier, + &svc.Limits, + &svc.Status, + &svc.ProviderResourceID, + &svc.ProviderMetadata, + &svc.CreatedAt, + &svc.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, errors.NewNotFoundError("Service") + } + if err != nil { + return nil, errors.NewDatabaseError(err) + } + + return svc, nil +} + +// ListByOrganization lists all services for an organization +func (r *ServiceRepository) ListByOrganization(ctx context.Context, orgID uuid.UUID) ([]*models.Service, error) { + query := ` + SELECT id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at + FROM services + WHERE organization_id = $1 AND status != 'deleting' + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(ctx, query, orgID) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + defer rows.Close() + + var services []*models.Service + for rows.Next() { + svc := &models.Service{} + err := rows.Scan( + &svc.ID, + &svc.OrganizationID, + &svc.Name, + &svc.ServiceType, + &svc.Engine, + &svc.Region, + &svc.Endpoint, + &svc.Port, + &svc.Token, + &svc.Credentials, + &svc.Config, + &svc.Tier, + &svc.Limits, + &svc.Status, + &svc.ProviderResourceID, + &svc.ProviderMetadata, + &svc.CreatedAt, + &svc.UpdatedAt, + ) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + services = append(services, svc) + } + + return services, nil +} + +// ListByOrganizationAndType lists services by organization and type +func (r *ServiceRepository) ListByOrganizationAndType(ctx context.Context, orgID uuid.UUID, serviceType models.ServiceType) ([]*models.Service, error) { + query := ` + SELECT id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at + FROM services + WHERE organization_id = $1 AND service_type = $2 AND status != 'deleting' + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(ctx, query, orgID, serviceType) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + defer rows.Close() + + var services []*models.Service + for rows.Next() { + svc := &models.Service{} + err := rows.Scan( + &svc.ID, + &svc.OrganizationID, + &svc.Name, + &svc.ServiceType, + &svc.Engine, + &svc.Region, + &svc.Endpoint, + &svc.Port, + &svc.Token, + &svc.Credentials, + &svc.Config, + &svc.Tier, + &svc.Limits, + &svc.Status, + &svc.ProviderResourceID, + &svc.ProviderMetadata, + &svc.CreatedAt, + &svc.UpdatedAt, + ) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + services = append(services, svc) + } + + return services, nil +} + +// CountByOrganization counts services for an organization +func (r *ServiceRepository) CountByOrganization(ctx context.Context, orgID uuid.UUID) (int, error) { + query := `SELECT COUNT(*) FROM services WHERE organization_id = $1 AND status != 'deleting'` + + var count int + err := r.db.QueryRow(ctx, query, orgID).Scan(&count) + if err != nil { + return 0, errors.NewDatabaseError(err) + } + + return count, nil +} + +// CountByOrganizationAndType counts services by org and type +func (r *ServiceRepository) CountByOrganizationAndType(ctx context.Context, orgID uuid.UUID, serviceType models.ServiceType) (int, error) { + query := `SELECT COUNT(*) FROM services WHERE organization_id = $1 AND service_type = $2 AND status != 'deleting'` + + var count int + err := r.db.QueryRow(ctx, query, orgID, serviceType).Scan(&count) + if err != nil { + return 0, errors.NewDatabaseError(err) + } + + return count, nil +} + +// UpdateStatus updates service status +func (r *ServiceRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status models.ServiceStatus) error { + query := `UPDATE services SET status = $2, updated_at = NOW() WHERE id = $1` + + _, err := r.db.Exec(ctx, query, id, status) + if err != nil { + return errors.NewDatabaseError(err) + } + + return nil +} + +// UpdateCredentials updates service credentials and token +func (r *ServiceRepository) UpdateCredentials(ctx context.Context, id uuid.UUID, credentials json.RawMessage, token string) error { + query := `UPDATE services SET credentials = $2, token = $3, updated_at = NOW() WHERE id = $1` + + _, err := r.db.Exec(ctx, query, id, credentials, token) + if err != nil { + return errors.NewDatabaseError(err) + } + + return nil +} + +// Delete soft-deletes a service +func (r *ServiceRepository) Delete(ctx context.Context, id uuid.UUID) error { + query := `UPDATE services SET status = 'deleting', updated_at = NOW() WHERE id = $1` + + _, err := r.db.Exec(ctx, query, id) + if err != nil { + return errors.NewDatabaseError(err) + } + + return nil +} + +// AvailableEngineRepository handles available engine operations +type AvailableEngineRepository struct { + db *pgxpool.Pool +} + +// NewAvailableEngineRepository creates a new available engine repository +func NewAvailableEngineRepository(db *pgxpool.Pool) *AvailableEngineRepository { + return &AvailableEngineRepository{db: db} +} + +// List returns all active available engines +func (r *AvailableEngineRepository) List(ctx context.Context) ([]*models.AvailableEngine, error) { + query := ` + SELECT id, service_type, engine, display_name, description, icon_url, is_active, regions, + base_price_monthly, price_per_request, price_per_gb_storage, price_per_gb_bandwidth, + free_tier_limits, default_config, created_at, updated_at + FROM available_engines + WHERE is_active = true + ORDER BY service_type, display_name + ` + + rows, err := r.db.Query(ctx, query) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + defer rows.Close() + + var engines []*models.AvailableEngine + for rows.Next() { + eng := &models.AvailableEngine{} + var regions pgtype.FlatArray[string] + err := rows.Scan( + &eng.ID, + &eng.ServiceType, + &eng.Engine, + &eng.DisplayName, + &eng.Description, + &eng.IconURL, + &eng.IsActive, + ®ions, + &eng.BasePriceMonthly, + &eng.PricePerRequest, + &eng.PricePerGBStorage, + &eng.PricePerGBBandwidth, + &eng.FreeTierLimits, + &eng.DefaultConfig, + &eng.CreatedAt, + &eng.UpdatedAt, + ) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + eng.Regions = regions + engines = append(engines, eng) + } + + return engines, nil +} + +// ListByServiceType returns available engines for a service type +func (r *AvailableEngineRepository) ListByServiceType(ctx context.Context, serviceType models.ServiceType) ([]*models.AvailableEngine, error) { + query := ` + SELECT id, service_type, engine, display_name, description, icon_url, is_active, regions, + base_price_monthly, price_per_request, price_per_gb_storage, price_per_gb_bandwidth, + free_tier_limits, default_config, created_at, updated_at + FROM available_engines + WHERE service_type = $1 AND is_active = true + ORDER BY display_name + ` + + rows, err := r.db.Query(ctx, query, serviceType) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + defer rows.Close() + + var engines []*models.AvailableEngine + for rows.Next() { + eng := &models.AvailableEngine{} + var regions pgtype.FlatArray[string] + err := rows.Scan( + &eng.ID, + &eng.ServiceType, + &eng.Engine, + &eng.DisplayName, + &eng.Description, + &eng.IconURL, + &eng.IsActive, + ®ions, + &eng.BasePriceMonthly, + &eng.PricePerRequest, + &eng.PricePerGBStorage, + &eng.PricePerGBBandwidth, + &eng.FreeTierLimits, + &eng.DefaultConfig, + &eng.CreatedAt, + &eng.UpdatedAt, + ) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + eng.Regions = regions + engines = append(engines, eng) + } + + return engines, nil +} + +// GetByEngine returns a specific engine configuration +func (r *AvailableEngineRepository) GetByEngine(ctx context.Context, serviceType models.ServiceType, engine models.ServiceEngine) (*models.AvailableEngine, error) { + query := ` + SELECT id, service_type, engine, display_name, description, icon_url, is_active, regions, + base_price_monthly, price_per_request, price_per_gb_storage, price_per_gb_bandwidth, + free_tier_limits, default_config, created_at, updated_at + FROM available_engines + WHERE service_type = $1 AND engine = $2 + ` + + eng := &models.AvailableEngine{} + var regions pgtype.FlatArray[string] + err := r.db.QueryRow(ctx, query, serviceType, engine).Scan( + &eng.ID, + &eng.ServiceType, + &eng.Engine, + &eng.DisplayName, + &eng.Description, + &eng.IconURL, + &eng.IsActive, + ®ions, + &eng.BasePriceMonthly, + &eng.PricePerRequest, + &eng.PricePerGBStorage, + &eng.PricePerGBBandwidth, + &eng.FreeTierLimits, + &eng.DefaultConfig, + &eng.CreatedAt, + &eng.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, errors.NewNotFoundError("Engine") + } + if err != nil { + return nil, errors.NewDatabaseError(err) + } + + eng.Regions = regions + return eng, nil +} diff --git a/services/api/internal/services/service_service.go b/services/api/internal/services/service_service.go new file mode 100644 index 0000000..533739c --- /dev/null +++ b/services/api/internal/services/service_service.go @@ -0,0 +1,350 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/lazycache-com/lazycache/internal/auth" + "github.com/lazycache-com/lazycache/internal/config" + "github.com/lazycache-com/lazycache/internal/errors" + "github.com/lazycache-com/lazycache/internal/models" + "github.com/lazycache-com/lazycache/internal/repository" +) + +// ServiceService handles unified service operations +type ServiceService struct { + svcRepo *repository.ServiceRepository + engineRepo *repository.AvailableEngineRepository + orgRepo *repository.OrganizationRepository + cfg *config.Config +} + +// NewServiceService creates a new service service +func NewServiceService( + svcRepo *repository.ServiceRepository, + engineRepo *repository.AvailableEngineRepository, + orgRepo *repository.OrganizationRepository, + cfg *config.Config, +) *ServiceService { + return &ServiceService{ + svcRepo: svcRepo, + engineRepo: engineRepo, + orgRepo: orgRepo, + cfg: cfg, + } +} + +// Create creates a new service instance +func (s *ServiceService) Create(ctx context.Context, orgID uuid.UUID, req *models.CreateServiceRequest) (*models.ServiceResponse, error) { + // Validate engine exists and is active + engine, err := s.engineRepo.GetByEngine(ctx, req.ServiceType, req.Engine) + if err != nil { + return nil, err + } + if !engine.IsActive { + return nil, errors.NewValidationError("Engine is not available") + } + + // Validate region is available for this engine + validRegion := false + for _, r := range engine.Regions { + if r == req.Region { + validRegion = true + break + } + } + if !validRegion { + return nil, errors.NewValidationError("Region not available for this engine") + } + + // Get organization and check plan limits + org, err := s.orgRepo.GetByID(ctx, orgID) + if err != nil { + return nil, err + } + + // Check service count limit based on plan + maxServices := s.getMaxServicesForPlan(org.Plan) + count, err := s.svcRepo.CountByOrganization(ctx, orgID) + if err != nil { + return nil, err + } + if maxServices > 0 && count >= maxServices { + return nil, errors.NewQuotaExceededError("Service limit reached for your plan") + } + + // Generate credentials based on service type + credentials, err := s.generateCredentials(req.ServiceType, req.Engine) + if err != nil { + return nil, errors.NewInternalError(err) + } + + credentialsJSON, err := json.Marshal(credentials) + if err != nil { + return nil, errors.NewInternalError(err) + } + + // Generate REST API token + token, err := auth.GenerateDatabaseToken() + if err != nil { + return nil, errors.NewInternalError(err) + } + + // Get default config and limits from engine + configJSON := engine.DefaultConfig + if req.Config != nil { + configJSON = req.Config + } + + // Create service record + svcID := uuid.New() + service := &models.Service{ + ID: svcID, + OrganizationID: orgID, + Name: req.Name, + ServiceType: req.ServiceType, + Engine: req.Engine, + Region: req.Region, + Endpoint: s.getEndpointForService(req.ServiceType, req.Engine, req.Region), + Port: models.GetDefaultPort(req.Engine), + Token: token, + Credentials: credentialsJSON, + Config: configJSON, + Tier: "free", + Limits: engine.FreeTierLimits, + Status: models.ServiceStatusCreating, + } + + if err := s.svcRepo.Create(ctx, service); err != nil { + return nil, err + } + + // For development/shared tier, mark as active immediately + if s.cfg.IsDevelopment() || service.Tier == "free" { + if err := s.svcRepo.UpdateStatus(ctx, svcID, models.ServiceStatusActive); err != nil { + return nil, err + } + service.Status = models.ServiceStatusActive + } + + return s.toResponse(service, credentials, true), nil +} + +// Get retrieves a service by ID +func (s *ServiceService) Get(ctx context.Context, orgID, svcID uuid.UUID) (*models.ServiceResponse, error) { + service, err := s.svcRepo.GetByID(ctx, svcID) + if err != nil { + return nil, err + } + + // Check ownership + if service.OrganizationID != orgID { + return nil, errors.NewNotFoundError("Service") + } + + return s.toResponse(service, nil, false), nil +} + +// List lists all services for an organization +func (s *ServiceService) List(ctx context.Context, orgID uuid.UUID, serviceType *models.ServiceType) ([]*models.ServiceResponse, error) { + var services []*models.Service + var err error + + if serviceType != nil { + services, err = s.svcRepo.ListByOrganizationAndType(ctx, orgID, *serviceType) + } else { + services, err = s.svcRepo.ListByOrganization(ctx, orgID) + } + + if err != nil { + return nil, err + } + + responses := make([]*models.ServiceResponse, len(services)) + for i, svc := range services { + responses[i] = s.toResponse(svc, nil, false) + } + + return responses, nil +} + +// Delete deletes a service +func (s *ServiceService) Delete(ctx context.Context, orgID, svcID uuid.UUID) error { + service, err := s.svcRepo.GetByID(ctx, svcID) + if err != nil { + return err + } + + // Check ownership + if service.OrganizationID != orgID { + return errors.NewNotFoundError("Service") + } + + // Mark as deleting (soft delete) + return s.svcRepo.Delete(ctx, svcID) +} + +// ResetCredentials generates new credentials for a service +func (s *ServiceService) ResetCredentials(ctx context.Context, orgID, svcID uuid.UUID) (*models.ServiceResponse, error) { + service, err := s.svcRepo.GetByID(ctx, svcID) + if err != nil { + return nil, err + } + + // Check ownership + if service.OrganizationID != orgID { + return nil, errors.NewNotFoundError("Service") + } + + // Generate new credentials + credentials, err := s.generateCredentials(service.ServiceType, service.Engine) + if err != nil { + return nil, errors.NewInternalError(err) + } + + credentialsJSON, err := json.Marshal(credentials) + if err != nil { + return nil, errors.NewInternalError(err) + } + + // Generate new token + token, err := auth.GenerateDatabaseToken() + if err != nil { + return nil, errors.NewInternalError(err) + } + + // Update service + if err := s.svcRepo.UpdateCredentials(ctx, svcID, credentialsJSON, token); err != nil { + return nil, err + } + + service.Token = token + service.Credentials = credentialsJSON + + return s.toResponse(service, credentials, true), nil +} + +// GetByToken retrieves a service by REST API token +func (s *ServiceService) GetByToken(ctx context.Context, token string) (*models.Service, error) { + return s.svcRepo.GetByToken(ctx, token) +} + +// GetAvailableEngines returns available engines +func (s *ServiceService) GetAvailableEngines(ctx context.Context, serviceType *models.ServiceType) ([]*models.AvailableEngine, error) { + if serviceType != nil { + return s.engineRepo.ListByServiceType(ctx, *serviceType) + } + return s.engineRepo.List(ctx) +} + +// GetRegions returns available regions for an engine +func (s *ServiceService) GetRegions(ctx context.Context, serviceType models.ServiceType, engine models.ServiceEngine) ([]string, error) { + eng, err := s.engineRepo.GetByEngine(ctx, serviceType, engine) + if err != nil { + return nil, err + } + return eng.Regions, nil +} + +// generateCredentials creates appropriate credentials for the service type +func (s *ServiceService) generateCredentials(serviceType models.ServiceType, engine models.ServiceEngine) (*models.ServiceCredentials, error) { + creds := &models.ServiceCredentials{} + + switch serviceType { + case models.ServiceTypeCache: + // Redis/ValKey need password and key prefix + password, err := auth.GenerateRandomPassword(32) + if err != nil { + return nil, err + } + creds.Password = password + creds.KeyPrefix = fmt.Sprintf("%s:", uuid.New().String()[:8]) + + case models.ServiceTypeDatabase, models.ServiceTypeVector: + // PostgreSQL/pgvector need username and password + password, err := auth.GenerateRandomPassword(32) + if err != nil { + return nil, err + } + creds.Username = fmt.Sprintf("user_%s", uuid.New().String()[:8]) + creds.Password = password + + case models.ServiceTypeStorage: + // S3/MinIO need access key and secret key + accessKey, err := auth.GenerateRandomPassword(20) + if err != nil { + return nil, err + } + secretKey, err := auth.GenerateRandomPassword(40) + if err != nil { + return nil, err + } + creds.AccessKey = accessKey + creds.SecretKey = secretKey + } + + return creds, nil +} + +// getEndpointForService returns the appropriate endpoint for a service +func (s *ServiceService) getEndpointForService(serviceType models.ServiceType, engine models.ServiceEngine, region string) string { + if s.cfg.IsDevelopment() { + switch serviceType { + case models.ServiceTypeCache: + return "localhost" + case models.ServiceTypeDatabase, models.ServiceTypeVector: + return "localhost" + case models.ServiceTypeStorage: + return "localhost" + default: + return "localhost" + } + } + + // Production endpoints + prefix := string(engine) + return fmt.Sprintf("%s-%s.lazycache.com", prefix, region) +} + +// getMaxServicesForPlan returns the maximum services allowed for a plan +func (s *ServiceService) getMaxServicesForPlan(plan string) int { + switch plan { + case "free": + return 3 + case "pro": + return 10 + case "team": + return 50 + case "business": + return -1 // unlimited + default: + return 3 + } +} + +// toResponse converts a service model to API response +func (s *ServiceService) toResponse(svc *models.Service, creds *models.ServiceCredentials, includeToken bool) *models.ServiceResponse { + resp := &models.ServiceResponse{ + ID: svc.ID, + Name: svc.Name, + ServiceType: svc.ServiceType, + Engine: svc.Engine, + Region: svc.Region, + Endpoint: svc.Endpoint, + Port: svc.Port, + Status: svc.Status, + Tier: svc.Tier, + Config: svc.Config, + Limits: svc.Limits, + CreatedAt: svc.CreatedAt, + RESTEndpoint: fmt.Sprintf("https://api.lazycache.com/v1/services/%s", svc.ID.String()), + } + + if includeToken { + resp.Token = svc.Token + } + + return resp +} diff --git a/services/api/migrations/002_multi_service_support.down.sql b/services/api/migrations/002_multi_service_support.down.sql new file mode 100644 index 0000000..3439ba2 --- /dev/null +++ b/services/api/migrations/002_multi_service_support.down.sql @@ -0,0 +1,17 @@ +-- ============================================================================= +-- Multi-Service Support Migration - ROLLBACK +-- ============================================================================= + +-- Drop triggers +DROP TRIGGER IF EXISTS update_services_updated_at ON services; +DROP TRIGGER IF EXISTS update_available_engines_updated_at ON available_engines; + +-- Drop tables +DROP TABLE IF EXISTS service_metrics; +DROP TABLE IF EXISTS services; +DROP TABLE IF EXISTS available_engines; + +-- Drop types +DROP TYPE IF EXISTS service_status; +DROP TYPE IF EXISTS service_engine; +DROP TYPE IF EXISTS service_type; diff --git a/services/api/migrations/002_multi_service_support.up.sql b/services/api/migrations/002_multi_service_support.up.sql new file mode 100644 index 0000000..969e052 --- /dev/null +++ b/services/api/migrations/002_multi_service_support.up.sql @@ -0,0 +1,236 @@ +-- ============================================================================= +-- Multi-Service Support Migration +-- ============================================================================= +-- Adds support for multiple service types: Cache, Database, Vector, Storage +-- Each service type can have multiple engine options (Redis/ValKey, PostgreSQL, etc.) +-- ============================================================================= + +-- ============================================================================= +-- Service Types Enum +-- ============================================================================= +CREATE TYPE service_type AS ENUM ('cache', 'database', 'vector', 'storage'); +CREATE TYPE service_engine AS ENUM ( + -- Cache engines + 'redis', 'valkey', + -- Database engines + 'postgresql', + -- Vector engines + 'pgvector', + -- Storage engines + 's3', 'minio' +); +CREATE TYPE service_status AS ENUM ('creating', 'active', 'paused', 'deleting', 'error'); + +-- ============================================================================= +-- Unified Services Table +-- ============================================================================= +CREATE TABLE services ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + -- Service identification + name VARCHAR(255) NOT NULL, + service_type service_type NOT NULL, + engine service_engine NOT NULL, + + -- Location + region VARCHAR(50) NOT NULL, + + -- Connection info + endpoint VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + + -- Authentication + token VARCHAR(255) NOT NULL UNIQUE, + credentials JSONB NOT NULL DEFAULT '{}', -- Encrypted credentials storage + + -- Configuration (engine-specific) + config JSONB NOT NULL DEFAULT '{}', + + -- Limits + tier VARCHAR(50) NOT NULL DEFAULT 'free', + limits JSONB NOT NULL DEFAULT '{}', + + -- Status + status service_status NOT NULL DEFAULT 'creating', + + -- Cloud provider reference (for AWS/GCP/Azure managed services) + provider_resource_id VARCHAR(255), + provider_metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Indexes for services +CREATE INDEX idx_services_organization_id ON services(organization_id); +CREATE INDEX idx_services_token ON services(token); +CREATE INDEX idx_services_service_type ON services(service_type); +CREATE INDEX idx_services_engine ON services(engine); +CREATE INDEX idx_services_status ON services(status); +CREATE INDEX idx_services_region ON services(region); + +-- ============================================================================= +-- Service Usage Metrics (Updated) +-- ============================================================================= +CREATE TABLE service_metrics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + + -- Time bucket + date DATE NOT NULL, + hour INTEGER NOT NULL CHECK (hour >= 0 AND hour <= 23), + + -- Common metrics + requests_count BIGINT NOT NULL DEFAULT 0, + storage_bytes BIGINT NOT NULL DEFAULT 0, + bandwidth_in BIGINT NOT NULL DEFAULT 0, + bandwidth_out BIGINT NOT NULL DEFAULT 0, + + -- Engine-specific metrics + metrics JSONB NOT NULL DEFAULT '{}', + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE(service_id, date, hour) +); + +CREATE INDEX idx_service_metrics_service_id ON service_metrics(service_id); +CREATE INDEX idx_service_metrics_date ON service_metrics(date); + +-- ============================================================================= +-- Available Engines Configuration +-- ============================================================================= +CREATE TABLE available_engines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + service_type service_type NOT NULL, + engine service_engine NOT NULL, + + -- Display info + display_name VARCHAR(100) NOT NULL, + description TEXT, + icon_url VARCHAR(500), + + -- Availability + is_active BOOLEAN NOT NULL DEFAULT true, + regions TEXT[] NOT NULL DEFAULT '{}', -- Available regions + + -- Pricing (per unit) + base_price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0, + price_per_request DECIMAL(10,6) NOT NULL DEFAULT 0, + price_per_gb_storage DECIMAL(10,4) NOT NULL DEFAULT 0, + price_per_gb_bandwidth DECIMAL(10,4) NOT NULL DEFAULT 0, + + -- Limits for free tier + free_tier_limits JSONB NOT NULL DEFAULT '{}', + + -- Default configuration + default_config JSONB NOT NULL DEFAULT '{}', + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + UNIQUE(service_type, engine) +); + +-- ============================================================================= +-- Seed Available Engines +-- ============================================================================= +INSERT INTO available_engines (service_type, engine, display_name, description, regions, free_tier_limits, default_config) VALUES +-- Cache engines +('cache', 'redis', 'Redis', 'The original in-memory data store. Fast, reliable, feature-rich.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_memory_mb": 256, "max_connections": 100, "max_commands_per_day": 500000}', + '{"maxmemory_policy": "volatile-lru", "appendonly": true}'), + +('cache', 'valkey', 'ValKey', 'Open source Redis fork by Linux Foundation. BSD licensed, community-driven.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_memory_mb": 256, "max_connections": 100, "max_commands_per_day": 500000}', + '{"maxmemory_policy": "volatile-lru", "appendonly": true}'), + +-- Database engines +('database', 'postgresql', 'PostgreSQL', 'The world''s most advanced open source relational database.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_storage_gb": 1, "max_connections": 20, "max_queries_per_day": 100000}', + '{"max_connections": 100, "shared_buffers": "128MB"}'), + +-- Vector engines +('vector', 'pgvector', 'pgvector', 'Open-source vector similarity search for PostgreSQL.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_storage_gb": 1, "max_vectors": 100000, "max_dimensions": 1536}', + '{"ivfflat_lists": 100, "hnsw_m": 16, "hnsw_ef_construction": 64}'), + +-- Storage engines +('storage', 's3', 'S3 Compatible', 'Amazon S3 compatible object storage.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_storage_gb": 5, "max_objects": 10000, "max_bandwidth_gb": 10}', + '{"versioning": false, "encryption": "AES256"}'), + +('storage', 'minio', 'MinIO', 'High-performance, S3 compatible object storage.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_storage_gb": 5, "max_objects": 10000, "max_bandwidth_gb": 10}', + '{"versioning": false, "encryption": "AES256"}'); + +-- ============================================================================= +-- Migrate existing redis_instances to services +-- ============================================================================= +INSERT INTO services ( + id, organization_id, name, service_type, engine, region, + endpoint, port, token, credentials, config, tier, limits, status, + provider_resource_id, created_at, updated_at +) +SELECT + id, + organization_id, + name, + 'cache'::service_type, + 'redis'::service_engine, + region, + endpoint, + port, + token, + jsonb_build_object('password', password, 'key_prefix', key_prefix), + jsonb_build_object('max_memory_mb', max_memory_mb, 'max_connections', max_connections), + tier, + jsonb_build_object('max_memory_mb', max_memory_mb, 'max_connections', max_connections), + status::text::service_status, + elasticache_cluster_id, + created_at, + updated_at +FROM redis_instances; + +-- Migrate usage_metrics to service_metrics +INSERT INTO service_metrics ( + id, service_id, date, hour, requests_count, storage_bytes, + bandwidth_in, bandwidth_out, metrics, created_at +) +SELECT + id, + database_id, + date, + hour, + commands_count, + storage_bytes, + bandwidth_in, + bandwidth_out, + jsonb_build_object('peak_connections', peak_connections), + created_at +FROM usage_metrics; + +-- ============================================================================= +-- Update triggers +-- ============================================================================= +CREATE TRIGGER update_services_updated_at + BEFORE UPDATE ON services + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_available_engines_updated_at + BEFORE UPDATE ON available_engines + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================================= +-- Note: We keep redis_instances and usage_metrics for backward compatibility +-- They can be dropped in a future migration after API is updated +-- ============================================================================= diff --git a/services/dashboard/index.html b/services/dashboard/index.html index e8f195c..7d695a3 100644 --- a/services/dashboard/index.html +++ b/services/dashboard/index.html @@ -2,7 +2,7 @@ - + LazyCache - Unified Data Platform diff --git a/services/dashboard/public/logo.svg b/services/dashboard/public/logo.svg index 653b43e..c1d1211 100644 --- a/services/dashboard/public/logo.svg +++ b/services/dashboard/public/logo.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/services/dashboard/src/assets/logo.svg b/services/dashboard/src/assets/logo.svg index 653b43e..c1d1211 100644 --- a/services/dashboard/src/assets/logo.svg +++ b/services/dashboard/src/assets/logo.svg @@ -1 +1 @@ - \ No newline at end of file + From 834e7832e88ace7bb5c6cc4f3512668847372d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Can=20Erdog=CC=86an?= Date: Mon, 26 Jan 2026 18:46:05 +0300 Subject: [PATCH 2/3] Fix CI build failures - Fix golangci-lint S1017: use strings.TrimPrefix unconditionally - Update vulnerable dependencies: - github.com/golang-jwt/jwt/v5 v5.2.0 -> v5.2.2 (CVE-2025-30204) - golang.org/x/crypto v0.17.0 -> v0.35.0 (CVE-2024-45337, CVE-2025-22869) - Format all Terraform files with terraform fmt - Add ESLint configuration for dashboard Co-Authored-By: Claude Opus 4.5 --- .../terraform/environments/dev/main.tf | 48 +- .../terraform/environments/prod/main.tf | 92 +-- .../terraform/modules/elasticache/main.tf | 4 +- infrastructure/terraform/modules/rds/main.tf | 12 +- infrastructure/terraform/modules/s3/main.tf | 4 +- infrastructure/terraform/variables.tf | 8 +- services/api/go.mod | 14 +- services/api/go.sum | 24 +- services/api/internal/handlers/redis_proxy.go | 4 +- services/dashboard/.eslintrc.cjs | 19 + services/dashboard/index.html | 28 +- services/dashboard/src/App.tsx | 30 +- services/dashboard/src/api/client.ts | 84 ++- services/dashboard/src/components/Layout.tsx | 4 +- services/dashboard/src/index.css | 124 ++++ services/dashboard/src/pages/Dashboard.tsx | 178 +++-- .../dashboard/src/pages/DatabaseDetail.tsx | 5 +- services/dashboard/src/pages/Databases.tsx | 5 +- services/dashboard/src/pages/Landing.tsx | 683 ++++++++++++++++++ services/dashboard/src/pages/Login.tsx | 7 +- services/dashboard/src/pages/Register.tsx | 7 +- .../dashboard/src/pages/ServiceDetail.tsx | 493 +++++++++++++ services/dashboard/src/pages/Services.tsx | 637 ++++++++++++++++ services/dashboard/src/pages/Settings.tsx | 5 +- services/dashboard/tailwind.config.js | 109 +++ 25 files changed, 2446 insertions(+), 182 deletions(-) create mode 100644 services/dashboard/.eslintrc.cjs create mode 100644 services/dashboard/src/pages/Landing.tsx create mode 100644 services/dashboard/src/pages/ServiceDetail.tsx create mode 100644 services/dashboard/src/pages/Services.tsx diff --git a/infrastructure/terraform/environments/dev/main.tf b/infrastructure/terraform/environments/dev/main.tf index 19c0571..63219f2 100644 --- a/infrastructure/terraform/environments/dev/main.tf +++ b/infrastructure/terraform/environments/dev/main.tf @@ -114,12 +114,12 @@ data "aws_availability_zones" "available" { module "vpc_primary" { source = "../../modules/vpc" - name_prefix = local.name_prefix - cidr_block = local.regions["us-east-1"].cidr_block - az_count = local.regions["us-east-1"].az_count - environment = local.environment - enable_nat = true # Single NAT for dev (cost optimization) - single_nat = true + name_prefix = local.name_prefix + cidr_block = local.regions["us-east-1"].cidr_block + az_count = local.regions["us-east-1"].az_count + environment = local.environment + enable_nat = true # Single NAT for dev (cost optimization) + single_nat = true tags = local.common_tags } @@ -131,22 +131,22 @@ module "vpc_primary" { module "rds_primary" { source = "../../modules/rds" - name_prefix = local.name_prefix - vpc_id = module.vpc_primary.vpc_id - subnet_ids = module.vpc_primary.private_subnet_ids - security_group_ids = [module.vpc_primary.rds_security_group_id] + name_prefix = local.name_prefix + vpc_id = module.vpc_primary.vpc_id + subnet_ids = module.vpc_primary.private_subnet_ids + security_group_ids = [module.vpc_primary.rds_security_group_id] - instance_class = "db.t3.micro" - allocated_storage = 20 - engine_version = "15" - multi_az = false # Single AZ for dev - backup_retention = 7 + instance_class = "db.t3.micro" + allocated_storage = 20 + engine_version = "15" + multi_az = false # Single AZ for dev + backup_retention = 7 - database_name = "redisaas" - master_username = "redisaas_admin" + database_name = "redisaas" + master_username = "redisaas_admin" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= @@ -161,12 +161,12 @@ module "elasticache_primary" { subnet_ids = module.vpc_primary.private_subnet_ids security_group_ids = [module.vpc_primary.redis_security_group_id] - node_type = "cache.t3.micro" - num_cache_nodes = 1 # Single node for dev - engine_version = "7.0" + node_type = "cache.t3.micro" + num_cache_nodes = 1 # Single node for dev + engine_version = "7.0" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= diff --git a/infrastructure/terraform/environments/prod/main.tf b/infrastructure/terraform/environments/prod/main.tf index f273b61..d800907 100644 --- a/infrastructure/terraform/environments/prod/main.tf +++ b/infrastructure/terraform/environments/prod/main.tf @@ -121,12 +121,12 @@ data "aws_caller_identity" "current" {} module "vpc_us" { source = "../../modules/vpc" - name_prefix = "${local.name_prefix}-us" - cidr_block = local.regions["us-east-1"].cidr_block - az_count = local.regions["us-east-1"].az_count - environment = local.environment - enable_nat = true - single_nat = false # HA NAT for prod + name_prefix = "${local.name_prefix}-us" + cidr_block = local.regions["us-east-1"].cidr_block + az_count = local.regions["us-east-1"].az_count + environment = local.environment + enable_nat = true + single_nat = false # HA NAT for prod tags = local.common_tags } @@ -141,12 +141,12 @@ module "vpc_eu" { aws = aws.eu } - name_prefix = "${local.name_prefix}-eu" - cidr_block = local.regions["eu-central-1"].cidr_block - az_count = local.regions["eu-central-1"].az_count - environment = local.environment - enable_nat = true - single_nat = false + name_prefix = "${local.name_prefix}-eu" + cidr_block = local.regions["eu-central-1"].cidr_block + az_count = local.regions["eu-central-1"].az_count + environment = local.environment + enable_nat = true + single_nat = false tags = local.common_tags } @@ -161,12 +161,12 @@ module "vpc_ap" { aws = aws.ap } - name_prefix = "${local.name_prefix}-ap" - cidr_block = local.regions["ap-southeast-1"].cidr_block - az_count = local.regions["ap-southeast-1"].az_count - environment = local.environment - enable_nat = true - single_nat = false + name_prefix = "${local.name_prefix}-ap" + cidr_block = local.regions["ap-southeast-1"].cidr_block + az_count = local.regions["ap-southeast-1"].az_count + environment = local.environment + enable_nat = true + single_nat = false tags = local.common_tags } @@ -178,22 +178,22 @@ module "vpc_ap" { module "rds_primary" { source = "../../modules/rds" - name_prefix = "${local.name_prefix}-us" - vpc_id = module.vpc_us.vpc_id - subnet_ids = module.vpc_us.private_subnet_ids - security_group_ids = [module.vpc_us.rds_security_group_id] + name_prefix = "${local.name_prefix}-us" + vpc_id = module.vpc_us.vpc_id + subnet_ids = module.vpc_us.private_subnet_ids + security_group_ids = [module.vpc_us.rds_security_group_id] - instance_class = "db.t3.small" # Upgraded for prod - allocated_storage = 50 - engine_version = "15" - multi_az = true # HA for prod - backup_retention = 14 + instance_class = "db.t3.small" # Upgraded for prod + allocated_storage = 50 + engine_version = "15" + multi_az = true # HA for prod + backup_retention = 14 - database_name = "redisaas" - master_username = "redisaas_admin" + database_name = "redisaas" + master_username = "redisaas_admin" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= @@ -208,12 +208,12 @@ module "elasticache_us" { subnet_ids = module.vpc_us.private_subnet_ids security_group_ids = [module.vpc_us.redis_security_group_id] - node_type = "cache.t3.small" # Upgraded for prod - num_cache_nodes = 2 # Primary + replica - engine_version = "7.0" + node_type = "cache.t3.small" # Upgraded for prod + num_cache_nodes = 2 # Primary + replica + engine_version = "7.0" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= @@ -231,12 +231,12 @@ module "elasticache_eu" { subnet_ids = module.vpc_eu.private_subnet_ids security_group_ids = [module.vpc_eu.redis_security_group_id] - node_type = "cache.t3.small" - num_cache_nodes = 2 - engine_version = "7.0" + node_type = "cache.t3.small" + num_cache_nodes = 2 + engine_version = "7.0" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= @@ -254,12 +254,12 @@ module "elasticache_ap" { subnet_ids = module.vpc_ap.private_subnet_ids security_group_ids = [module.vpc_ap.redis_security_group_id] - node_type = "cache.t3.small" - num_cache_nodes = 2 - engine_version = "7.0" + node_type = "cache.t3.small" + num_cache_nodes = 2 + engine_version = "7.0" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= diff --git a/infrastructure/terraform/modules/elasticache/main.tf b/infrastructure/terraform/modules/elasticache/main.tf index 4c2fe16..150c2fa 100644 --- a/infrastructure/terraform/modules/elasticache/main.tf +++ b/infrastructure/terraform/modules/elasticache/main.tf @@ -7,8 +7,8 @@ # ============================================================================= resource "random_password" "auth_token" { - length = 32 - special = false # ElastiCache auth token doesn't support all special chars + length = 32 + special = false # ElastiCache auth token doesn't support all special chars } # ============================================================================= diff --git a/infrastructure/terraform/modules/rds/main.tf b/infrastructure/terraform/modules/rds/main.tf index 4160cbe..5055262 100644 --- a/infrastructure/terraform/modules/rds/main.tf +++ b/infrastructure/terraform/modules/rds/main.tf @@ -62,22 +62,22 @@ resource "aws_db_parameter_group" "main" { # Performance parameters parameter { name = "shared_buffers" - value = "{DBInstanceClassMemory/4096}" # 25% of memory + value = "{DBInstanceClassMemory/4096}" # 25% of memory } parameter { name = "effective_cache_size" - value = "{DBInstanceClassMemory*3/4096}" # 75% of memory + value = "{DBInstanceClassMemory*3/4096}" # 75% of memory } parameter { name = "work_mem" - value = "65536" # 64MB + value = "65536" # 64MB } parameter { name = "maintenance_work_mem" - value = "524288" # 512MB + value = "524288" # 512MB } # Logging @@ -88,7 +88,7 @@ resource "aws_db_parameter_group" "main" { parameter { name = "log_min_duration_statement" - value = "1000" # Log queries > 1s + value = "1000" # Log queries > 1s } # Connection @@ -191,7 +191,7 @@ resource "aws_cloudwatch_metric_alarm" "storage_low" { namespace = "AWS/RDS" period = 300 statistic = "Average" - threshold = 5368709120 # 5GB in bytes + threshold = 5368709120 # 5GB in bytes alarm_description = "RDS free storage space is low" dimensions = { diff --git a/infrastructure/terraform/modules/s3/main.tf b/infrastructure/terraform/modules/s3/main.tf index 4d305fa..0c6c0f4 100644 --- a/infrastructure/terraform/modules/s3/main.tf +++ b/infrastructure/terraform/modules/s3/main.tf @@ -151,8 +151,8 @@ resource "aws_s3_bucket_policy" "static" { Version = "2012-10-17" Statement = [ { - Sid = "AllowCloudFrontOAC" - Effect = "Allow" + Sid = "AllowCloudFrontOAC" + Effect = "Allow" Principal = { Service = "cloudfront.amazonaws.com" } diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf index c1de555..ef54685 100644 --- a/infrastructure/terraform/variables.tf +++ b/infrastructure/terraform/variables.tf @@ -36,10 +36,10 @@ variable "primary_region" { variable "regions" { description = "Map of regions with their configurations" type = map(object({ - enabled = bool - cidr_block = string - is_primary = bool - az_count = number + enabled = bool + cidr_block = string + is_primary = bool + az_count = number })) default = { "us-east-1" = { diff --git a/services/api/go.mod b/services/api/go.mod index 5873ef9..f02ce1b 100644 --- a/services/api/go.mod +++ b/services/api/go.mod @@ -1,17 +1,17 @@ module github.com/lazycache-com/lazycache -go 1.23 +go 1.23.0 toolchain go1.24.4 require ( github.com/gin-gonic/gin v1.9.1 - github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.5.0 github.com/jackc/pgx/v5 v5.5.1 github.com/redis/go-redis/v9 v9.3.1 github.com/rs/zerolog v1.31.0 - golang.org/x/crypto v0.17.0 + golang.org/x/crypto v0.35.0 ) require ( @@ -42,10 +42,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.6.0 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/services/api/go.sum b/services/api/go.sum index f6bd2d9..f6df77b 100644 --- a/services/api/go.sum +++ b/services/api/go.sum @@ -39,8 +39,8 @@ github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -107,20 +107,20 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.6.0 h1:S0JTfE48HbRj80+4tbvZDYsJ3tGv6BUU3XxyZ7CirAc= golang.org/x/arch v0.6.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= diff --git a/services/api/internal/handlers/redis_proxy.go b/services/api/internal/handlers/redis_proxy.go index ac0c9d9..04d7a67 100644 --- a/services/api/internal/handlers/redis_proxy.go +++ b/services/api/internal/handlers/redis_proxy.go @@ -242,9 +242,7 @@ func (h *RedisProxyHandler) TokenAuthMiddleware() gin.HandlerFunc { } // Remove Bearer prefix if present - if strings.HasPrefix(token, "Bearer ") { - token = strings.TrimPrefix(token, "Bearer ") - } + token = strings.TrimPrefix(token, "Bearer ") // Database tokens are 64 hex characters if len(token) != 64 { diff --git a/services/dashboard/.eslintrc.cjs b/services/dashboard/.eslintrc.cjs new file mode 100644 index 0000000..41721dc --- /dev/null +++ b/services/dashboard/.eslintrc.cjs @@ -0,0 +1,19 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, +} diff --git a/services/dashboard/index.html b/services/dashboard/index.html index 7d695a3..230909e 100644 --- a/services/dashboard/index.html +++ b/services/dashboard/index.html @@ -4,7 +4,33 @@ - LazyCache - Unified Data Platform + + + LazyCache - The Unified Data Platform + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/services/dashboard/src/App.tsx b/services/dashboard/src/App.tsx index 1e688cc..929e4b4 100644 --- a/services/dashboard/src/App.tsx +++ b/services/dashboard/src/App.tsx @@ -1,10 +1,11 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { useAuthStore } from './store/auth' +import Landing from './pages/Landing' import Login from './pages/Login' import Register from './pages/Register' import Dashboard from './pages/Dashboard' -import Databases from './pages/Databases' -import DatabaseDetail from './pages/DatabaseDetail' +import Services from './pages/Services' +import ServiceDetail from './pages/ServiceDetail' import Settings from './pages/Settings' import Layout from './components/Layout' @@ -28,9 +29,22 @@ function PublicRoute({ children }: { children: React.ReactNode }) { return <>{children} } +function LandingRoute() { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + if (isAuthenticated) { + return + } + + return +} + export default function App() { return ( + {/* Landing page - shows to unauthenticated users, redirects to dashboard if logged in */} + } /> + {/* Public routes */} - {/* Private routes */} + {/* Private routes with Layout wrapper */} } > - } /> } /> - } /> - } /> + } /> + } /> } /> + {/* Backward compatibility - redirect old routes */} + } /> + } /> + {/* Catch all */} } /> diff --git a/services/dashboard/src/api/client.ts b/services/dashboard/src/api/client.ts index 5ffb8b3..9de4d7b 100644 --- a/services/dashboard/src/api/client.ts +++ b/services/dashboard/src/api/client.ts @@ -72,7 +72,7 @@ export const authApi = { me: () => api.get('/auth/me'), } -// Database API +// Database API (legacy - kept for backward compatibility) export interface Database { id: string name: string @@ -104,6 +104,88 @@ export const databaseApi = { getRegions: () => api.get<{ regions: string[] }>('/databases/regions'), } +// Service Types +export type ServiceType = 'cache' | 'database' | 'vector' | 'storage' +export type ServiceEngine = 'redis' | 'valkey' | 'postgresql' | 'pgvector' | 's3' | 'minio' + +export interface Service { + id: string + name: string + service_type: ServiceType + engine: ServiceEngine + region: string + endpoint: string + port: number + status: string + tier: string + config?: Record + limits?: Record + created_at: string + rest_endpoint: string + token?: string +} + +export interface AvailableEngine { + id: string + service_type: ServiceType + engine: ServiceEngine + display_name: string + description?: string + icon_url?: string + is_active: boolean + regions: string[] + base_price_monthly: number + price_per_request: number + price_per_gb_storage: number + price_per_gb_bandwidth: number + free_tier_limits?: Record + default_config?: Record +} + +export interface ServiceTypeInfo { + type: ServiceType + name: string + description: string + engines: string[] +} + +// Services API +export const servicesApi = { + // List all services or filter by type + list: (type?: ServiceType) => + api.get<{ services: Service[] }>('/services', { params: type ? { type } : undefined }), + + // Get a specific service + get: (id: string) => api.get(`/services/${id}`), + + // Create a new service + create: (data: { + name: string + service_type: ServiceType + engine: ServiceEngine + region: string + config?: Record + }) => api.post('/services', data), + + // Delete a service + delete: (id: string) => api.delete(`/services/${id}`), + + // Reset service credentials + resetCredentials: (id: string) => + api.post(`/services/${id}/reset-credentials`), + + // Get available service types + getTypes: () => api.get<{ types: ServiceTypeInfo[] }>('/services/types'), + + // Get available engines (optionally filter by service type) + getEngines: (type?: ServiceType) => + api.get<{ engines: AvailableEngine[] }>('/services/engines', { params: type ? { type } : undefined }), + + // Get available regions for a specific engine + getRegions: (type: ServiceType, engine: ServiceEngine) => + api.get<{ regions: string[] }>('/services/regions', { params: { type, engine } }), +} + // User API export interface ApiKey { id: string diff --git a/services/dashboard/src/components/Layout.tsx b/services/dashboard/src/components/Layout.tsx index 425cc99..4d13028 100644 --- a/services/dashboard/src/components/Layout.tsx +++ b/services/dashboard/src/components/Layout.tsx @@ -2,7 +2,7 @@ import { Outlet, Link, useLocation } from 'react-router-dom' import { useAuthStore } from '../store/auth' import { LayoutDashboard, - Database, + Layers, Settings, LogOut, Menu, @@ -13,7 +13,7 @@ import clsx from 'clsx' const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, - { name: 'Databases', href: '/databases', icon: Database }, + { name: 'Services', href: '/services', icon: Layers }, { name: 'Settings', href: '/settings', icon: Settings }, ] diff --git a/services/dashboard/src/index.css b/services/dashboard/src/index.css index 239044d..7f52285 100644 --- a/services/dashboard/src/index.css +++ b/services/dashboard/src/index.css @@ -1,3 +1,6 @@ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap'); +@import url('https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@400,500,700,800&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; @@ -35,3 +38,124 @@ body { @apply bg-white rounded-lg shadow-sm border border-gray-200 p-6; } } + +/* Landing Page Styles */ +.font-display { + font-family: 'Cabinet Grotesk', 'Inter', sans-serif; +} + +.text-gradient { + background: linear-gradient(to right, #06b6d4, #a855f7, #ec4899); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.text-gradient-green { + background: linear-gradient(to right, #22c55e, #06b6d4); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.glow-cyan { + box-shadow: 0 0 40px rgba(6, 182, 212, 0.3); +} + +.glow-purple { + box-shadow: 0 0 40px rgba(168, 85, 247, 0.3); +} + +.glow-green { + box-shadow: 0 0 40px rgba(34, 197, 94, 0.3); +} + +.glass { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.glass-dark { + background: rgba(10, 15, 26, 0.8); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +/* Terminal Animation */ +.terminal-text::after { + content: '|'; + animation: blink 1s step-end infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Scrollbar Styling for Landing */ +.landing-scroll::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.landing-scroll::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; +} + +.landing-scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; +} + +.landing-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Code Block Styling */ +.code-block { + @apply bg-void-950 rounded-lg overflow-hidden; +} + +.code-block pre { + @apply p-4 text-sm font-mono overflow-x-auto; +} + +/* Noise Texture Overlay */ +.noise-overlay::before { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); + opacity: 0.03; + pointer-events: none; +} + +/* Gradient Border Animation */ +.gradient-border { + position: relative; + background: linear-gradient(var(--void-900), var(--void-900)) padding-box, + linear-gradient(135deg, #06b6d4, #a855f7, #ec4899) border-box; + border: 1px solid transparent; +} + +/* Hover Card Effect */ +.hover-lift { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.hover-lift:hover { + transform: translateY(-4px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); +} + +/* Stagger animation delays */ +.stagger-1 { animation-delay: 0.1s; } +.stagger-2 { animation-delay: 0.2s; } +.stagger-3 { animation-delay: 0.3s; } +.stagger-4 { animation-delay: 0.4s; } +.stagger-5 { animation-delay: 0.5s; } +.stagger-6 { animation-delay: 0.6s; } diff --git a/services/dashboard/src/pages/Dashboard.tsx b/services/dashboard/src/pages/Dashboard.tsx index d8d3fd2..4391b10 100644 --- a/services/dashboard/src/pages/Dashboard.tsx +++ b/services/dashboard/src/pages/Dashboard.tsx @@ -1,19 +1,53 @@ import { Link } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { databaseApi } from '../api/client' +import { servicesApi, Service } from '../api/client' import { useAuthStore } from '../store/auth' -import { Database, Plus, ArrowRight, Activity } from 'lucide-react' +import { + Database, + Plus, + ArrowRight, + Activity, + Cpu, + HardDrive, + Archive, + Layers +} from 'lucide-react' +import clsx from 'clsx' + +// Service type icons +const serviceTypeIcons = { + cache: Cpu, + database: Database, + vector: HardDrive, + storage: Archive, +} + +// Engine colors +const engineColors: Record = { + redis: { bg: 'bg-red-100', text: 'text-red-600' }, + valkey: { bg: 'bg-blue-100', text: 'text-blue-600' }, + postgresql: { bg: 'bg-blue-100', text: 'text-blue-700' }, + pgvector: { bg: 'bg-purple-100', text: 'text-purple-600' }, + s3: { bg: 'bg-orange-100', text: 'text-orange-600' }, + minio: { bg: 'bg-pink-100', text: 'text-pink-600' }, +} export default function Dashboard() { const user = useAuthStore((state) => state.user) - const { data: databasesData, isLoading } = useQuery({ - queryKey: ['databases'], - queryFn: () => databaseApi.list(), + const { data: servicesData, isLoading } = useQuery({ + queryKey: ['services'], + queryFn: () => servicesApi.list(), }) - const databases = databasesData?.data?.databases || [] - const activeDatabases = databases.filter((db) => db.status === 'active') + const services = servicesData?.data?.services || [] + const activeServices = services.filter((svc) => svc.status === 'active') + + // Count services by type + const cacheCount = services.filter((s) => s.service_type === 'cache').length + const dbCount = services.filter((s) => s.service_type === 'database').length + const vectorCount = services.filter((s) => s.service_type === 'vector').length + const storageCount = services.filter((s) => s.service_type === 'storage').length return (
@@ -23,24 +57,24 @@ export default function Dashboard() { Welcome back, {user?.name}

- Here's what's happening with your Redis databases today. + Here's what's happening with your data services today.

{/* Stats */} -
+

- Total Databases + Total Services

- {databases.length} + {services.length}

- +
@@ -50,7 +84,7 @@ export default function Dashboard() {

Active

- {activeDatabases.length} + {activeServices.length}

@@ -75,6 +109,30 @@ export default function Dashboard() {
+ +
+
+

By Type

+
+
+ + {cacheCount} cache +
+
+ + {dbCount} db +
+
+ + {vectorCount} vector +
+
+ + {storageCount} storage +
+
+
+
{/* Quick actions */} @@ -84,7 +142,7 @@ export default function Dashboard() {
@@ -92,25 +150,25 @@ export default function Dashboard() {
-

Create Database

+

Create Service

- Spin up a new Redis instance + Spin up a new data service

- +
-

View Databases

+

View Services

- Manage your existing databases + Manage your existing services

@@ -118,14 +176,14 @@ export default function Dashboard() { - {/* Recent databases */} + {/* Recent services */}

- Recent Databases + Recent Services

View all @@ -134,45 +192,22 @@ export default function Dashboard() { {isLoading ? (
Loading...
- ) : databases.length === 0 ? ( + ) : services.length === 0 ? (
- -

No databases yet

+ +

No services yet

- Create your first database + Create your first service
) : (
- {databases.slice(0, 5).map((db) => ( - -
- -
-
-

- {db.name} -

-

{db.region}

-
- - {db.status} - - + {services.slice(0, 5).map((svc) => ( + ))}
)} @@ -180,3 +215,40 @@ export default function Dashboard() {
) } + +function ServiceRow({ service }: { service: Service }) { + const Icon = serviceTypeIcons[service.service_type] || Database + const colors = engineColors[service.engine] || { bg: 'bg-gray-100', text: 'text-gray-600' } + + return ( + +
+ +
+
+
+

+ {service.name} +

+ + {service.engine} + +
+

{service.region}

+
+ + {service.status} + + + ) +} diff --git a/services/dashboard/src/pages/DatabaseDetail.tsx b/services/dashboard/src/pages/DatabaseDetail.tsx index b200540..8016e2f 100644 --- a/services/dashboard/src/pages/DatabaseDetail.tsx +++ b/services/dashboard/src/pages/DatabaseDetail.tsx @@ -103,8 +103,9 @@ export default function DatabaseDetail() { onSuccess: (response) => { setCommandResult(JSON.stringify(response.data.result, null, 2)) }, - onError: (error: any) => { - const message = error.response?.data?.error?.message || 'Command failed' + onError: (error: unknown) => { + const axiosError = error as { response?: { data?: { error?: { message?: string } } } } + const message = axiosError.response?.data?.error?.message || 'Command failed' setCommandResult(`Error: ${message}`) }, }) diff --git a/services/dashboard/src/pages/Databases.tsx b/services/dashboard/src/pages/Databases.tsx index ebfa265..3ed735c 100644 --- a/services/dashboard/src/pages/Databases.tsx +++ b/services/dashboard/src/pages/Databases.tsx @@ -166,8 +166,9 @@ function CreateDatabaseModal({ toast.success('Database created!') onCreated(response.data) }, - onError: (error: any) => { - const message = error.response?.data?.error?.message || 'Failed to create database' + onError: (error: unknown) => { + const axiosError = error as { response?: { data?: { error?: { message?: string } } } } + const message = axiosError.response?.data?.error?.message || 'Failed to create database' toast.error(message) }, }) diff --git a/services/dashboard/src/pages/Landing.tsx b/services/dashboard/src/pages/Landing.tsx new file mode 100644 index 0000000..8d0c087 --- /dev/null +++ b/services/dashboard/src/pages/Landing.tsx @@ -0,0 +1,683 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { + Database, + Cpu, + HardDrive, + Archive, + Zap, + Shield, + Globe, + Code2, + Github, + ArrowRight, + Check, + Sparkles, + Layers, + Lock, + Clock, + ChevronRight, +} from 'lucide-react' +import clsx from 'clsx' + +// Terminal typing effect hook +function useTypingEffect(text: string, speed: number = 50) { + const [displayText, setDisplayText] = useState('') + const [isComplete, setIsComplete] = useState(false) + + useEffect(() => { + let index = 0 + setDisplayText('') + setIsComplete(false) + + const timer = setInterval(() => { + if (index < text.length) { + setDisplayText(text.slice(0, index + 1)) + index++ + } else { + setIsComplete(true) + clearInterval(timer) + } + }, speed) + + return () => clearInterval(timer) + }, [text, speed]) + + return { displayText, isComplete } +} + +// Service cards data +const services = [ + { + name: 'Cache', + description: 'Redis & ValKey compatible caching with sub-millisecond latency', + icon: Cpu, + color: 'neon-cyan', + gradient: 'from-cyan-500 to-blue-500', + engines: ['Redis', 'ValKey'], + }, + { + name: 'Database', + description: 'Serverless PostgreSQL with automatic scaling and branching', + icon: Database, + color: 'neon-purple', + gradient: 'from-purple-500 to-pink-500', + engines: ['PostgreSQL'], + }, + { + name: 'Vector', + description: 'AI-native vector database for embeddings and similarity search', + icon: HardDrive, + color: 'neon-green', + gradient: 'from-green-500 to-emerald-500', + engines: ['pgvector'], + }, + { + name: 'Storage', + description: 'S3-compatible object storage with global CDN distribution', + icon: Archive, + color: 'neon-orange', + gradient: 'from-orange-500 to-amber-500', + engines: ['S3', 'MinIO'], + }, +] + +// Features data +const features = [ + { + icon: Zap, + title: 'Blazing Fast', + description: 'Sub-millisecond response times with edge deployment', + }, + { + icon: Shield, + title: 'Enterprise Security', + description: 'SOC2 compliant with encryption at rest and in transit', + }, + { + icon: Globe, + title: 'Global Scale', + description: 'Deploy to 30+ regions worldwide with automatic failover', + }, + { + icon: Code2, + title: 'Developer First', + description: 'REST API, native SDKs, and CLI for every workflow', + }, + { + icon: Lock, + title: 'Open Source', + description: 'MIT licensed core with full transparency and control', + }, + { + icon: Clock, + title: 'Pay Per Use', + description: 'Only pay for what you use with no hidden fees', + }, +] + +// Pricing plans +const plans = [ + { + name: 'Free', + price: '$0', + period: 'forever', + description: 'Perfect for side projects and experiments', + features: [ + '1 service per type', + '256 MB storage', + '10K requests/day', + 'Community support', + 'Single region', + ], + cta: 'Start Free', + popular: false, + }, + { + name: 'Pro', + price: '$19', + period: '/month', + description: 'For individual developers and small teams', + features: [ + '5 services per type', + '10 GB storage', + '1M requests/day', + 'Email support', + 'Multi-region', + 'Daily backups', + ], + cta: 'Start Trial', + popular: true, + }, + { + name: 'Team', + price: '$49', + period: '/month', + description: 'For growing teams with advanced needs', + features: [ + '25 services per type', + '100 GB storage', + '10M requests/day', + 'Priority support', + 'Team collaboration', + 'Custom domains', + 'Analytics dashboard', + ], + cta: 'Start Trial', + popular: false, + }, + { + name: 'Business', + price: '$199', + period: '/month', + description: 'For enterprises requiring dedicated resources', + features: [ + 'Unlimited services', + '1 TB storage', + 'Unlimited requests', + '24/7 phone support', + 'Dedicated instances', + 'SLA guarantee', + 'Custom integrations', + 'SOC2 & HIPAA', + ], + cta: 'Contact Sales', + popular: false, + }, +] + +// Code examples +const codeExamples = { + cache: `// Connect to LazyCache Redis +import { LazyCache } from '@lazycache/sdk' + +const cache = new LazyCache({ + url: process.env.LAZYCACHE_URL, + token: process.env.LAZYCACHE_TOKEN, +}) + +// Set and get with automatic serialization +await cache.set('user:1', { name: 'John', role: 'admin' }) +const user = await cache.get('user:1') + +// Built-in rate limiting +const limiter = cache.ratelimit({ + requests: 100, + window: '1m', +})`, + database: `// Serverless PostgreSQL connection +import { LazyDB } from '@lazycache/db' + +const db = new LazyDB({ + connectionString: process.env.DATABASE_URL, +}) + +// Type-safe queries with automatic pooling +const users = await db.query\` + SELECT * FROM users + WHERE created_at > \${lastWeek} + ORDER BY created_at DESC + LIMIT 10 +\` + +// Transactions with automatic retry +await db.transaction(async (tx) => { + await tx.insert('orders', order) + await tx.update('inventory', { id }, { stock: stock - 1 }) +})`, + vector: `// AI-ready vector search +import { LazyVector } from '@lazycache/vector' + +const vectors = new LazyVector({ + url: process.env.VECTOR_URL, +}) + +// Store embeddings with metadata +await vectors.upsert('products', [ + { + id: 'prod-1', + embedding: await openai.embed(description), + metadata: { category: 'electronics', price: 299 }, + }, +]) + +// Semantic search with filtering +const similar = await vectors.search('products', { + vector: queryEmbedding, + topK: 10, + filter: { category: 'electronics' }, +})`, + storage: `// S3-compatible object storage +import { LazyStorage } from '@lazycache/storage' + +const storage = new LazyStorage({ + bucket: process.env.STORAGE_BUCKET, + accessKey: process.env.STORAGE_KEY, +}) + +// Upload with automatic CDN distribution +const url = await storage.upload('images/avatar.png', file, { + contentType: 'image/png', + public: true, +}) + +// Presigned URLs for secure access +const downloadUrl = await storage.presign('private/report.pdf', { + expiresIn: '1h', +})`, +} + +export default function Landing() { + const [activeTab, setActiveTab] = useState('cache') + const { displayText, isComplete } = useTypingEffect('npx create-lazycache@latest my-app', 40) + + return ( +
+ {/* Background effects */} +
+
+ + {/* Gradient orbs */} +
+
+
+ + {/* Navigation */} + + + {/* Hero Section */} +
+
+
+ {/* Badge */} +
+ + Open Source & AI-Ready + +
+ + {/* Main headline */} +

+ The Unified +
+ Data Platform +

+ +

+ Cache, Database, Vector Search, and Storage — all in one place. + Built for developers who ship fast. +

+ + {/* Terminal */} +
+
+
+
+
+
+ terminal +
+
+
+ $ + {displayText} + {!isComplete && |} +
+
+
+
+ + {/* CTA buttons */} +
+ + Start Building Free + + + + + View on GitHub + +
+
+
+
+ + {/* Services Section */} +
+
+
+

+ One Platform, All Your Data +

+

+ Stop juggling multiple services. LazyCache unifies your entire data stack + with a consistent API and developer experience. +

+
+ +
+ {services.map((service, i) => ( +
+
+ +
+

{service.name}

+

{service.description}

+
+ {service.engines.map((engine) => ( + + {engine} + + ))} +
+
+ ))} +
+
+
+ + {/* Code Examples Section */} +
+
+
+
+

+ Built for Developers +

+

+ Clean, intuitive APIs that just work. Native SDKs for every major language, + comprehensive documentation, and a CLI that makes deployment a breeze. +

+ + {/* Tabs */} +
+ {Object.keys(codeExamples).map((tab) => ( + + ))} +
+ +
+
+ + TypeScript native +
+
+ + Auto-completion +
+
+ + Edge-ready +
+
+
+ +
+
+
+
+
+ {activeTab}.ts +
+
+                
+                  {codeExamples[activeTab]}
+                
+              
+
+
+
+
+ + {/* Features Section */} +
+
+
+

+ Why LazyCache? +

+

+ Everything you need to build modern applications, with none of the infrastructure headaches. +

+
+ +
+ {features.map((feature, i) => ( +
+
+ +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+
+ + {/* Pricing Section */} +
+
+
+

+ Simple, Transparent Pricing +

+

+ Start free, scale as you grow. No hidden fees, no surprises. +

+
+ +
+ {plans.map((plan) => ( +
+ {plan.popular && ( +
+ Most Popular +
+ )} + +
+

{plan.name}

+
+ {plan.price} + {plan.period} +
+

{plan.description}

+
+ +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + + {plan.cta} + +
+ ))} +
+
+
+ + {/* Open Source CTA */} +
+
+
+
+ +
+

+ 100% Open Source +

+

+ LazyCache is MIT licensed. Deploy anywhere — your infrastructure, your rules. + Self-host for free or use our managed cloud. +

+ +
+
+
+ + {/* Footer */} + +
+ ) +} diff --git a/services/dashboard/src/pages/Login.tsx b/services/dashboard/src/pages/Login.tsx index be3a335..a0f3b47 100644 --- a/services/dashboard/src/pages/Login.tsx +++ b/services/dashboard/src/pages/Login.tsx @@ -4,7 +4,7 @@ import { useMutation } from '@tanstack/react-query' import { authApi } from '../api/client' import { useAuthStore } from '../store/auth' import toast from 'react-hot-toast' -import { Database, Loader2 } from 'lucide-react' +import { Loader2 } from 'lucide-react' export default function Login() { const navigate = useNavigate() @@ -22,8 +22,9 @@ export default function Login() { toast.success('Welcome back!') navigate('/dashboard') }, - onError: (error: any) => { - const message = error.response?.data?.error?.message || 'Login failed' + onError: (error: unknown) => { + const axiosError = error as { response?: { data?: { error?: { message?: string } } } } + const message = axiosError.response?.data?.error?.message || 'Login failed' toast.error(message) }, }) diff --git a/services/dashboard/src/pages/Register.tsx b/services/dashboard/src/pages/Register.tsx index 213e6c8..6a09b66 100644 --- a/services/dashboard/src/pages/Register.tsx +++ b/services/dashboard/src/pages/Register.tsx @@ -4,7 +4,7 @@ import { useMutation } from '@tanstack/react-query' import { authApi } from '../api/client' import { useAuthStore } from '../store/auth' import toast from 'react-hot-toast' -import { Database, Loader2 } from 'lucide-react' +import { Loader2 } from 'lucide-react' export default function Register() { const navigate = useNavigate() @@ -24,8 +24,9 @@ export default function Register() { toast.success('Account created successfully!') navigate('/dashboard') }, - onError: (error: any) => { - const message = error.response?.data?.error?.message || 'Registration failed' + onError: (error: unknown) => { + const axiosError = error as { response?: { data?: { error?: { message?: string } } } } + const message = axiosError.response?.data?.error?.message || 'Registration failed' toast.error(message) }, }) diff --git a/services/dashboard/src/pages/ServiceDetail.tsx b/services/dashboard/src/pages/ServiceDetail.tsx new file mode 100644 index 0000000..c780645 --- /dev/null +++ b/services/dashboard/src/pages/ServiceDetail.tsx @@ -0,0 +1,493 @@ +import { useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { servicesApi, redisApi } from '../api/client' +import toast from 'react-hot-toast' +import { + Database, + Copy, + Check, + RefreshCw, + Trash2, + Terminal, + Play, + Loader2, + Eye, + EyeOff, + AlertTriangle, + Cpu, + HardDrive, + Archive, +} from 'lucide-react' +import clsx from 'clsx' + +// Service type icons +const serviceTypeIcons = { + cache: Cpu, + database: Database, + vector: HardDrive, + storage: Archive, +} + +// Engine colors +const engineColors: Record = { + redis: { bg: 'bg-red-100', text: 'text-red-600' }, + valkey: { bg: 'bg-blue-100', text: 'text-blue-600' }, + postgresql: { bg: 'bg-blue-100', text: 'text-blue-700' }, + pgvector: { bg: 'bg-purple-100', text: 'text-purple-600' }, + s3: { bg: 'bg-orange-100', text: 'text-orange-600' }, + minio: { bg: 'bg-pink-100', text: 'text-pink-600' }, +} + +// Parse Redis command respecting quoted strings +function parseRedisCommand(cmd: string): string[] { + const parts: string[] = [] + let current = '' + let inQuotes = false + let quoteChar = '' + + for (let i = 0; i < cmd.length; i++) { + const char = cmd[i] + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true + quoteChar = char + } else if (inQuotes && char === quoteChar) { + inQuotes = false + quoteChar = '' + } else if (!inQuotes && char === ' ') { + if (current) { + parts.push(current) + current = '' + } + } else { + current += char + } + } + + if (current) { + parts.push(current) + } + + return parts +} + +export default function ServiceDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [copiedField, setCopiedField] = useState(null) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [command, setCommand] = useState('') + const [commandResult, setCommandResult] = useState(null) + const [resetToken, setResetToken] = useState(null) + const [showResetToken, setShowResetToken] = useState(false) + + const { data: serviceData, isLoading } = useQuery({ + queryKey: ['service', id], + queryFn: () => servicesApi.get(id!), + enabled: !!id, + }) + + const deleteMutation = useMutation({ + mutationFn: () => servicesApi.delete(id!), + onSuccess: () => { + toast.success('Service deleted') + queryClient.invalidateQueries({ queryKey: ['services'] }) + navigate('/services') + }, + onError: () => { + toast.error('Failed to delete service') + }, + }) + + const resetCredentialsMutation = useMutation({ + mutationFn: () => servicesApi.resetCredentials(id!), + onSuccess: (response) => { + if (response.data.token) { + setResetToken(response.data.token) + setShowResetToken(false) + } + toast.success('Credentials reset successfully') + queryClient.invalidateQueries({ queryKey: ['service', id] }) + }, + onError: () => { + toast.error('Failed to reset credentials') + }, + }) + + // Redis command execution (only for cache services) + const executeCommandMutation = useMutation({ + mutationFn: (cmd: string) => { + const parts = parseRedisCommand(cmd) + return redisApi.command(id!, parts) + }, + onSuccess: (response) => { + setCommandResult(JSON.stringify(response.data.result, null, 2)) + }, + onError: (error: unknown) => { + const axiosError = error as { response?: { data?: { error?: { message?: string } } } } + const message = axiosError.response?.data?.error?.message || 'Command failed' + setCommandResult(`Error: ${message}`) + }, + }) + + const copyToClipboard = (text: string, field: string) => { + navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + toast.success('Copied!') + } + + const handleExecuteCommand = (e: React.FormEvent) => { + e.preventDefault() + if (!command.trim()) return + executeCommandMutation.mutate(command) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!serviceData?.data) { + return ( +
+

Service not found

+
+ ) + } + + const svc = serviceData.data + const Icon = serviceTypeIcons[svc.service_type] || Database + const colors = engineColors[svc.engine] || { bg: 'bg-gray-100', text: 'text-gray-600' } + const isCacheService = svc.service_type === 'cache' + + return ( +
+ {/* Header */} +
+
+
+ +
+
+
+

{svc.name}

+ + {svc.engine} + +
+

{svc.region} • {svc.service_type}

+
+
+ + {svc.status} + +
+ + {/* Connection Details */} +
+

+ Connection Details +

+
+ copyToClipboard(svc.rest_endpoint, 'rest')} + /> + copyToClipboard(`${svc.endpoint}:${svc.port}`, 'endpoint')} + /> +
+ +
+ +
+
+ + {/* Service Info */} +
+
+

Tier

+

+ {svc.tier} +

+
+
+

Service Type

+

+ {svc.service_type} +

+
+
+

Engine

+

+ {svc.engine} +

+
+
+ + {/* Limits */} + {svc.limits && Object.keys(svc.limits).length > 0 && ( +
+

Limits

+
+ {Object.entries(svc.limits).map(([key, value]) => ( +
+

{key.replace(/_/g, ' ')}

+

{String(value)}

+
+ ))} +
+
+ )} + + {/* CLI Console (only for cache services) */} + {isCacheService && ( +
+

+ + {svc.engine === 'redis' ? 'Redis' : 'ValKey'} Console +

+ +
+ setCommand(e.target.value)} + placeholder="Enter command (e.g., SET key value)" + className="input flex-1 font-mono" + /> + +
+ + {commandResult && ( +
+
+                {commandResult}
+              
+
+ )} + +
+

Example commands:

+
    +
  • SET mykey "Hello World"
  • +
  • GET mykey
  • +
  • KEYS *
  • +
  • INCR counter
  • +
+
+
+ )} + + {/* Danger Zone */} +
+

Danger Zone

+

+ Once you delete a service, there is no going back. Please be certain. +

+ +
+ + {/* Delete confirmation modal */} + {showDeleteConfirm && ( +
+
+
setShowDeleteConfirm(false)} + /> +
+

+ Delete Service +

+

+ Are you sure you want to delete "{svc.name}"? This action cannot + be undone. +

+
+ + +
+
+
+
+ )} + + {/* Reset Token Modal */} + {resetToken && ( +
+
+
+ +
+
+
+ +
+

+ New Credentials Generated +

+
+ +
+ +

+ This token will only be shown once. Make sure to save it securely. +

+
+ +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+
+
+ )} +
+ ) +} + +function ConnectionField({ + label, + value, + copied, + onCopy, +}: { + label: string + value: string + copied: boolean + onCopy: () => void +}) { + return ( +
+ +
+ + +
+
+ ) +} diff --git a/services/dashboard/src/pages/Services.tsx b/services/dashboard/src/pages/Services.tsx new file mode 100644 index 0000000..2c277dc --- /dev/null +++ b/services/dashboard/src/pages/Services.tsx @@ -0,0 +1,637 @@ +import { useState, useEffect } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { servicesApi, Service, ServiceType, ServiceEngine, AvailableEngine } from '../api/client' +import toast from 'react-hot-toast' +import { + Database as DatabaseIcon, + Plus, + Loader2, + X, + Copy, + Check, + HardDrive, + Cpu, + Archive +} from 'lucide-react' +import clsx from 'clsx' + +// Service type icons +const serviceTypeIcons: Record = { + cache: Cpu, + database: DatabaseIcon, + vector: HardDrive, + storage: Archive, +} + +// Engine display info +const engineInfo: Record = { + redis: { color: 'text-red-600', bgColor: 'bg-red-100' }, + valkey: { color: 'text-blue-600', bgColor: 'bg-blue-100' }, + postgresql: { color: 'text-blue-700', bgColor: 'bg-blue-100' }, + pgvector: { color: 'text-purple-600', bgColor: 'bg-purple-100' }, + s3: { color: 'text-orange-600', bgColor: 'bg-orange-100' }, + minio: { color: 'text-pink-600', bgColor: 'bg-pink-100' }, +} + +export default function Services() { + const location = useLocation() + const queryClient = useQueryClient() + const [showCreateModal, setShowCreateModal] = useState(false) + const [newService, setNewService] = useState(null) + const [filterType, setFilterType] = useState('') + + // Open modal if navigated with createNew state + useEffect(() => { + if (location.state?.createNew) { + setShowCreateModal(true) + } + }, [location.state]) + + const { data: servicesData, isLoading } = useQuery({ + queryKey: ['services', filterType], + queryFn: () => servicesApi.list(filterType || undefined), + }) + + const { data: typesData } = useQuery({ + queryKey: ['service-types'], + queryFn: () => servicesApi.getTypes(), + }) + + const services = servicesData?.data?.services || [] + const serviceTypes = typesData?.data?.types || [] + + return ( +
+ {/* Header */} +
+
+

Services

+

+ Manage your data services - Cache, Database, Vector, Storage +

+
+ +
+ + {/* Filter tabs */} +
+ + {serviceTypes.map((type) => { + const Icon = serviceTypeIcons[type.type] + return ( + + ) + })} +
+ + {/* Service list */} + {isLoading ? ( +
+ +

Loading services...

+
+ ) : services.length === 0 ? ( +
+ +

No services yet

+

+ Get started by creating your first service +

+ +
+ ) : ( +
+ {services.map((svc) => ( + + ))} +
+ )} + + {/* Create modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onCreated={(svc) => { + setNewService(svc) + setShowCreateModal(false) + queryClient.invalidateQueries({ queryKey: ['services'] }) + }} + /> + )} + + {/* New service credentials modal */} + {newService && ( + setNewService(null)} + /> + )} +
+ ) +} + +function ServiceCard({ service }: { service: Service }) { + const Icon = serviceTypeIcons[service.service_type] + const engineStyle = engineInfo[service.engine] || { color: 'text-gray-600', bgColor: 'bg-gray-100' } + + return ( + +
+
+ +
+
+ + {service.engine} + + + {service.status} + +
+
+ +

{service.name}

+

{service.region}

+ +
+
+ Type + + {service.service_type} + +
+
+ Tier + + {service.tier} + +
+
+ + ) +} + +function CreateServiceModal({ + onClose, + onCreated, +}: { + onClose: () => void + onCreated: (svc: Service) => void +}) { + const [step, setStep] = useState<'type' | 'engine' | 'config'>('type') + const [selectedType, setSelectedType] = useState(null) + const [selectedEngine, setSelectedEngine] = useState(null) + const [formData, setFormData] = useState({ + name: '', + region: '', + }) + + const { data: typesData } = useQuery({ + queryKey: ['service-types'], + queryFn: () => servicesApi.getTypes(), + }) + + const { data: enginesData } = useQuery({ + queryKey: ['engines', selectedType], + queryFn: () => servicesApi.getEngines(selectedType!), + enabled: !!selectedType, + }) + + const serviceTypes = typesData?.data?.types || [] + const engines = enginesData?.data?.engines || [] + + const createMutation = useMutation({ + mutationFn: servicesApi.create, + onSuccess: (response) => { + toast.success('Service created!') + onCreated(response.data) + }, + onError: (error: unknown) => { + const axiosError = error as { response?: { data?: { error?: { message?: string } } } } + const message = axiosError.response?.data?.error?.message || 'Failed to create service' + toast.error(message) + }, + }) + + const handleTypeSelect = (type: ServiceType) => { + setSelectedType(type) + setSelectedEngine(null) + setStep('engine') + } + + const handleEngineSelect = (engine: AvailableEngine) => { + setSelectedEngine(engine) + setFormData({ ...formData, region: engine.regions[0] || '' }) + setStep('config') + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!selectedType || !selectedEngine) return + + createMutation.mutate({ + name: formData.name, + service_type: selectedType, + engine: selectedEngine.engine, + region: formData.region, + }) + } + + const handleBack = () => { + if (step === 'config') { + setStep('engine') + } else if (step === 'engine') { + setStep('type') + setSelectedType(null) + } + } + + return ( +
+
+
+ +
+
+
+ {step !== 'type' && ( + + )} +

+ {step === 'type' && 'Choose Service Type'} + {step === 'engine' && 'Choose Engine'} + {step === 'config' && 'Configure Service'} +

+
+ +
+ + {/* Step 1: Service Type */} + {step === 'type' && ( +
+ {serviceTypes.map((type) => { + const Icon = serviceTypeIcons[type.type] + return ( + + ) + })} +
+ )} + + {/* Step 2: Engine Selection */} + {step === 'engine' && ( +
+ {engines.map((engine) => { + const style = engineInfo[engine.engine] || { color: 'text-gray-600', bgColor: 'bg-gray-100' } + return ( + + ) + })} +
+ )} + + {/* Step 3: Configuration */} + {step === 'config' && selectedEngine && ( +
+
+
+ + {selectedEngine.engine[0].toUpperCase()} + +
+
+

{selectedEngine.display_name}

+

{selectedType} service

+
+
+ +
+ + + setFormData({ ...formData, name: e.target.value }) + } + /> +
+ +
+ + +

+ Choose the region closest to your application +

+
+ + {selectedEngine.free_tier_limits && ( +
+

Free Tier Limits

+
+ {Object.entries(selectedEngine.free_tier_limits).map(([key, value]) => ( +
+ {key.replace(/_/g, ' ')}: + {String(value)} +
+ ))} +
+
+ )} + +
+ + +
+
+ )} +
+
+
+ ) +} + +function CredentialsModal({ + service, + onClose, +}: { + service: Service + onClose: () => void +}) { + const [copiedField, setCopiedField] = useState(null) + + const copyToClipboard = (text: string, field: string) => { + navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + toast.success('Copied to clipboard!') + } + + return ( +
+
+
+ +
+
+

+ Service Created! +

+ +
+ +
+

+ Important: Save these credentials now. The token + won't be shown again. +

+
+ +
+ copyToClipboard(service.rest_endpoint, 'endpoint')} + /> + + {service.token && ( + copyToClipboard(service.token!, 'token')} + masked + /> + )} + + + copyToClipboard( + `${service.endpoint}:${service.port}`, + 'native' + ) + } + /> +
+ +
+ + Go to Service + +
+
+
+
+ ) +} + +function CredentialField({ + label, + value, + copied, + onCopy, + masked = false, +}: { + label: string + value: string + copied: boolean + onCopy: () => void + masked?: boolean +}) { + const [showValue, setShowValue] = useState(!masked) + + return ( +
+ +
+ + {masked && ( + + )} + +
+
+ ) +} diff --git a/services/dashboard/src/pages/Settings.tsx b/services/dashboard/src/pages/Settings.tsx index 34dd614..ef08d95 100644 --- a/services/dashboard/src/pages/Settings.tsx +++ b/services/dashboard/src/pages/Settings.tsx @@ -183,8 +183,9 @@ function SecuritySection() { setNewPassword('') setConfirmPassword('') }, - onError: (error: any) => { - const message = error.response?.data?.error?.message || 'Failed to change password' + onError: (error: unknown) => { + const axiosError = error as { response?: { data?: { error?: { message?: string } } } } + const message = axiosError.response?.data?.error?.message || 'Failed to change password' toast.error(message) }, }) diff --git a/services/dashboard/tailwind.config.js b/services/dashboard/tailwind.config.js index 8d804d8..67e372d 100644 --- a/services/dashboard/tailwind.config.js +++ b/services/dashboard/tailwind.config.js @@ -19,6 +19,115 @@ export default { 800: '#166534', 900: '#14532d', }, + // Landing page colors - dark terminal aesthetic + void: { + 950: '#030712', + 900: '#0a0f1a', + 800: '#111827', + 700: '#1f2937', + }, + neon: { + cyan: '#06b6d4', + purple: '#a855f7', + green: '#22c55e', + pink: '#ec4899', + orange: '#f97316', + }, + }, + fontFamily: { + mono: ['JetBrains Mono', 'Fira Code', 'monospace'], + display: ['Cal Sans', 'Inter', 'sans-serif'], + }, + animation: { + 'gradient-x': 'gradient-x 15s ease infinite', + 'gradient-y': 'gradient-y 15s ease infinite', + 'gradient-xy': 'gradient-xy 15s ease infinite', + 'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'float': 'float 6s ease-in-out infinite', + 'glow': 'glow 2s ease-in-out infinite alternate', + 'typing': 'typing 3.5s steps(40, end)', + 'blink': 'blink 1s step-end infinite', + 'fade-in': 'fadeIn 0.5s ease-out forwards', + 'fade-in-up': 'fadeInUp 0.6s ease-out forwards', + 'slide-in-left': 'slideInLeft 0.5s ease-out forwards', + 'slide-in-right': 'slideInRight 0.5s ease-out forwards', + 'scale-in': 'scaleIn 0.3s ease-out forwards', + }, + keyframes: { + 'gradient-y': { + '0%, 100%': { + 'background-size': '400% 400%', + 'background-position': 'center top' + }, + '50%': { + 'background-size': '200% 200%', + 'background-position': 'center center' + } + }, + 'gradient-x': { + '0%, 100%': { + 'background-size': '200% 200%', + 'background-position': 'left center' + }, + '50%': { + 'background-size': '200% 200%', + 'background-position': 'right center' + } + }, + 'gradient-xy': { + '0%, 100%': { + 'background-size': '400% 400%', + 'background-position': 'left center' + }, + '50%': { + 'background-size': '200% 200%', + 'background-position': 'right center' + } + }, + float: { + '0%, 100%': { transform: 'translateY(0)' }, + '50%': { transform: 'translateY(-20px)' }, + }, + glow: { + '0%': { boxShadow: '0 0 20px rgba(6, 182, 212, 0.3)' }, + '100%': { boxShadow: '0 0 40px rgba(168, 85, 247, 0.5)' }, + }, + typing: { + '0%': { width: '0' }, + '100%': { width: '100%' }, + }, + blink: { + '0%, 100%': { opacity: '1' }, + '50%': { opacity: '0' }, + }, + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + fadeInUp: { + '0%': { opacity: '0', transform: 'translateY(20px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + slideInLeft: { + '0%': { opacity: '0', transform: 'translateX(-30px)' }, + '100%': { opacity: '1', transform: 'translateX(0)' }, + }, + slideInRight: { + '0%': { opacity: '0', transform: 'translateX(30px)' }, + '100%': { opacity: '1', transform: 'translateX(0)' }, + }, + scaleIn: { + '0%': { opacity: '0', transform: 'scale(0.9)' }, + '100%': { opacity: '1', transform: 'scale(1)' }, + }, + }, + backgroundImage: { + 'grid-pattern': 'linear-gradient(to right, rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.03) 1px, transparent 1px)', + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + }, + backgroundSize: { + 'grid': '60px 60px', }, }, }, From 8234f5e39d696f0eba4b39a7a55438c45a21cc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Can=20Erdog=CC=86an?= Date: Mon, 26 Jan 2026 18:50:08 +0300 Subject: [PATCH 3/3] Fix pgx SQL injection vulnerability Update github.com/jackc/pgx/v5 v5.5.1 -> v5.5.4 (CVE-2024-27304) Co-Authored-By: Claude Opus 4.5 --- services/api/go.mod | 2 +- services/api/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/api/go.mod b/services/api/go.mod index f02ce1b..28072d5 100644 --- a/services/api/go.mod +++ b/services/api/go.mod @@ -8,7 +8,7 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.5.0 - github.com/jackc/pgx/v5 v5.5.1 + github.com/jackc/pgx/v5 v5.5.4 github.com/redis/go-redis/v9 v9.3.1 github.com/rs/zerolog v1.31.0 golang.org/x/crypto v0.35.0 diff --git a/services/api/go.sum b/services/api/go.sum index f6df77b..ba0ea40 100644 --- a/services/api/go.sum +++ b/services/api/go.sum @@ -50,8 +50,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI= -github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=