From e902e5f32014aeb532e6c56eb4dbf527c423b3ee Mon Sep 17 00:00:00 2001 From: default Date: Wed, 4 Feb 2026 06:53:46 +0000 Subject: [PATCH] API and Database Deployment Update --- backend/cmd/server/main.go | 11 +- backend/internal/api/admin.go | 9 - backend/internal/api/api.go | 53 +- backend/internal/api/auth.go | 55 +- backend/internal/api/middleware.go | 17 +- backend/internal/api/project.go | 166 ++-- backend/internal/api/storage.go | 318 ++++++- backend/internal/api/stream.go | 16 - backend/internal/api/webhook.go | 20 +- backend/internal/auth/utils.go | 8 - backend/internal/builder/builder.go | 46 +- backend/internal/db/db.go | 7 - backend/internal/deployer/deployer.go | 96 +- backend/internal/models/models.go | 20 +- backend/internal/ports/ports.go | 18 +- frontend/src/lib/api.ts | 129 ++- frontend/src/lib/components/ui/tabs/index.ts | 16 + .../components/ui/tabs/tabs-content.svelte | 17 + .../lib/components/ui/tabs/tabs-list.svelte | 20 + .../components/ui/tabs/tabs-trigger.svelte | 20 + .../src/lib/components/ui/tabs/tabs.svelte | 19 + frontend/src/routes/+page.svelte | 4 +- frontend/src/routes/network/+page.svelte | 94 +- .../projects/[id]/deployments/+page.svelte | 35 +- .../projects/[id]/settings/+page.svelte | 367 +++++++- .../src/routes/settings/session/+page.svelte | 144 +++ frontend/src/routes/storage/+page.svelte | 826 ++++++++++++++++-- 27 files changed, 2156 insertions(+), 395 deletions(-) create mode 100644 frontend/src/lib/components/ui/tabs/index.ts create mode 100644 frontend/src/lib/components/ui/tabs/tabs-content.svelte create mode 100644 frontend/src/lib/components/ui/tabs/tabs-list.svelte create mode 100644 frontend/src/lib/components/ui/tabs/tabs-trigger.svelte create mode 100644 frontend/src/lib/components/ui/tabs/tabs.svelte create mode 100644 frontend/src/routes/settings/session/+page.svelte diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 088cfd1..beff3d0 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,32 +1,25 @@ package main - import ( "log" "time" - "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" - "clickploy/internal/api" "clickploy/internal/builder" "clickploy/internal/db" "clickploy/internal/deployer" "clickploy/internal/ports" ) - func main() { db.Init(".") - pm := ports.NewManager(4000, 5000) + pm := ports.NewManager(2000, 60000) buildr := builder.NewBuilder() dply, err := deployer.NewDeployer() if err != nil { log.Fatalf("Failed to create deployer: %v", err) } - handler := api.NewHandler(buildr, dply, pm) - r := gin.Default() - r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, @@ -35,7 +28,6 @@ func main() { AllowCredentials: true, MaxAge: 12 * time.Hour, })) - handler.RegisterRoutes(r) handler.RegisterAuthRoutes(r) handler.RegisterUserRoutes(r) @@ -44,7 +36,6 @@ func main() { handler.RegisterSystemRoutes(r) handler.RegisterStorageRoutes(r) handler.RegisterAdminRoutes(r) - log.Println("Starting Clickploy Backend on :8080") if err := r.Run(":8080"); err != nil { log.Fatalf("Server failed: %v", err) diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index a7882ba..9e963fc 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -1,14 +1,10 @@ package api - import ( "net/http" - "clickploy/internal/db" "clickploy/internal/models" - "github.com/gin-gonic/gin" ) - func (h *Handler) RegisterAdminRoutes(r *gin.Engine) { admin := r.Group("/api/admin", AuthMiddleware(), AdminMiddleware()) { @@ -17,7 +13,6 @@ func (h *Handler) RegisterAdminRoutes(r *gin.Engine) { admin.GET("/stats", h.adminGetStats) } } - func (h *Handler) adminListUsers(c *gin.Context) { var users []models.User if err := db.DB.Preload("Projects").Find(&users).Error; err != nil { @@ -26,7 +21,6 @@ func (h *Handler) adminListUsers(c *gin.Context) { } c.JSON(http.StatusOK, users) } - func (h *Handler) adminDeleteUser(c *gin.Context) { id := c.Param("id") if err := db.DB.Where("id = ?", id).Delete(&models.User{}).Error; err != nil { @@ -35,16 +29,13 @@ func (h *Handler) adminDeleteUser(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } - func (h *Handler) adminGetStats(c *gin.Context) { var userCount int64 var projectCount int64 var deploymentCount int64 - db.DB.Model(&models.User{}).Count(&userCount) db.DB.Model(&models.Project{}).Count(&projectCount) db.DB.Model(&models.Deployment{}).Count(&deploymentCount) - c.JSON(http.StatusOK, gin.H{ "users": userCount, "projects": projectCount, diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 0c93e88..742569c 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -1,15 +1,16 @@ package api import ( + "clickploy/internal/builder" + "clickploy/internal/deployer" + "clickploy/internal/ports" "fmt" + "io" + "net" "net/http" "os" "github.com/gin-gonic/gin" - - "clickploy/internal/builder" - "clickploy/internal/deployer" - "clickploy/internal/ports" ) type Handler struct { @@ -32,7 +33,6 @@ type DeployRequest struct { Port int `json:"port"` GitToken string `json:"git_token"` } - type DeployResponse struct { Status string `json:"status"` AppName string `json:"app_name"` @@ -45,32 +45,27 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { r.POST("/deploy", h.handleDeploy) h.RegisterStreamRoutes(r) } - func (h *Handler) handleDeploy(c *gin.Context) { var req DeployRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - - imageName, _, err := h.builder.Build(req.Repo, req.Name, req.GitToken, "", "", "", "", nil, os.Stdout) + imageName, _, err := h.builder.Build(req.Repo, "", req.Name, req.GitToken, "", "", "", "", nil, os.Stdout) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Build failed: %v", err)}) return } - port, err := h.ports.GetPort(req.Name, req.Port) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)}) return } - _, err = h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Deployment failed: %v", err)}) return } - c.JSON(http.StatusOK, DeployResponse{ Status: "success", AppName: req.Name, @@ -79,14 +74,42 @@ func (h *Handler) handleDeploy(c *gin.Context) { Message: "Container started successfully", }) } - func (h *Handler) RegisterSystemRoutes(r *gin.Engine) { r.GET("/api/system/status", h.handleSystemStatus) } - func (h *Handler) handleSystemStatus(c *gin.Context) { + localIP := GetLocalIP() + publicIP := GetPublicIP() c.JSON(http.StatusOK, gin.H{ - "version": "v0.1.0", - "status": "All systems normal", + "version": "v0.1.0", + "status": "All systems normal", + "local_ip": localIP, + "public_ip": publicIP, }) } +func GetLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "Unknown" + } + for _, address := range addrs { + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + return "Unknown" +} +func GetPublicIP() string { + resp, err := http.Get("https://api.ipify.org?format=text") + if err != nil { + return "Unknown" + } + defer resp.Body.Close() + ip, err := io.ReadAll(resp.Body) + if err != nil { + return "Unknown" + } + return string(ip) +} diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 2ad7613..39c988f 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -1,11 +1,12 @@ package api import ( - "net/http" - "clickploy/internal/auth" "clickploy/internal/db" "clickploy/internal/models" + "crypto/rand" + "encoding/hex" + "net/http" "github.com/gin-gonic/gin" gonanoid "github.com/matoous/go-nanoid/v2" @@ -16,7 +17,6 @@ type AuthRequest struct { Password string `json:"password" binding:"required,min=6"` Name string `json:"name"` } - type AuthResponse struct { Token string `json:"token"` User models.User `json:"user"` @@ -29,126 +29,131 @@ func (h *Handler) RegisterAuthRoutes(r *gin.Engine) { authGroup.POST("/login", h.login) } } - func (h *Handler) register(c *gin.Context) { var req AuthRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - hashed, err := auth.HashPassword(req.Password) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } - var count int64 db.DB.Model(&models.User{}).Count(&count) - + apiKeyBytes := make([]byte, 32) + rand.Read(apiKeyBytes) + apiKey := "cp_" + hex.EncodeToString(apiKeyBytes) userID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) user := models.User{ ID: userID, Email: req.Email, Password: hashed, Name: req.Name, - Avatar: "https://github.com/shadcn.png", + Avatar: "https://ui-avatars.com/api/?name=" + req.Name, IsAdmin: count == 0, + APIKey: apiKey, } - if result := db.DB.Create(&user); result.Error != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"}) return } - token, _ := auth.GenerateToken(user.ID, user.Email) c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user}) } - func (h *Handler) login(c *gin.Context) { var req AuthRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - var user models.User if result := db.DB.Where("email = ?", req.Email).First(&user); result.Error != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) return } - if !auth.CheckPassword(req.Password, user.Password) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) return } - token, _ := auth.GenerateToken(user.ID, user.Email) c.JSON(http.StatusOK, AuthResponse{Token: token, User: user}) } - func (h *Handler) RegisterUserRoutes(r *gin.Engine) { userGroup := r.Group("/api/user", AuthMiddleware()) { + userGroup.GET("/", h.getMe) userGroup.PUT("/profile", h.updateProfile) userGroup.PUT("/password", h.updatePassword) + userGroup.POST("/key", h.regenerateAPIKey) } } - func (h *Handler) updateProfile(c *gin.Context) { userID, _ := c.Get("userID") var req struct { Name string `json:"name"` Email string `json:"email"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - var user models.User if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } - if req.Name != "" { user.Name = req.Name } if req.Email != "" { user.Email = req.Email } - db.DB.Save(&user) c.JSON(http.StatusOK, user) } - func (h *Handler) updatePassword(c *gin.Context) { userID, _ := c.Get("userID") var req struct { OldPassword string `json:"old_password" binding:"required"` NewPassword string `json:"new_password" binding:"required,min=6"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - var user models.User if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } - if !auth.CheckPassword(req.OldPassword, user.Password) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Incorrect old password"}) return } - hashed, _ := auth.HashPassword(req.NewPassword) user.Password = hashed db.DB.Save(&user) c.JSON(http.StatusOK, gin.H{"message": "Password updated"}) } +func (h *Handler) getMe(c *gin.Context) { + userID, _ := c.Get("userID") + var user models.User + if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + c.JSON(http.StatusOK, user) +} +func (h *Handler) regenerateAPIKey(c *gin.Context) { + userID, _ := c.Get("userID") + apiKeyBytes := make([]byte, 32) + rand.Read(apiKeyBytes) + apiKey := "cp_" + hex.EncodeToString(apiKeyBytes) + if err := db.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"}) + return + } + c.JSON(http.StatusOK, gin.H{"api_key": apiKey}) +} diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index 7109690..78f565b 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -1,16 +1,12 @@ package api - import ( "net/http" "strings" - "clickploy/internal/auth" "clickploy/internal/db" "clickploy/internal/models" - "github.com/gin-gonic/gin" ) - func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") @@ -19,21 +15,25 @@ func AuthMiddleware() gin.HandlerFunc { c.Abort() return } - tokenString := strings.TrimPrefix(authHeader, "Bearer ") claims, err := auth.ValidateToken(tokenString) if err != nil { + var user models.User + if result := db.DB.Where("api_key = ?", tokenString).First(&user); result.Error == nil { + c.Set("userID", user.ID) + c.Set("email", user.Email) + c.Next() + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.Abort() return } - c.Set("userID", claims.UserID) c.Set("email", claims.Email) c.Next() } } - func AdminMiddleware() gin.HandlerFunc { return func(c *gin.Context) { userID, exists := c.Get("userID") @@ -42,20 +42,17 @@ func AdminMiddleware() gin.HandlerFunc { c.Abort() return } - var user models.User if err := db.DB.Where("id = ?", userID).First(&user).Error; err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) c.Abort() return } - if !user.IsAdmin { c.JSON(http.StatusForbidden, gin.H{"error": "Admin privileges required"}) c.Abort() return } - c.Next() } } diff --git a/backend/internal/api/project.go b/backend/internal/api/project.go index 40aaf7a..d391027 100644 --- a/backend/internal/api/project.go +++ b/backend/internal/api/project.go @@ -2,14 +2,15 @@ package api import ( "bytes" + "clickploy/internal/db" + "clickploy/internal/models" + "context" "crypto/rand" "encoding/hex" "fmt" "io" "net/http" - - "clickploy/internal/db" - "clickploy/internal/models" + "strings" "github.com/gin-gonic/gin" gonanoid "github.com/matoous/go-nanoid/v2" @@ -22,16 +23,51 @@ func (h *Handler) RegisterProjectRoutes(r *gin.Engine) { protected.POST("/projects", h.createProject) protected.GET("/projects", h.listProjects) protected.GET("/projects/:id", h.getProject) + protected.PUT("/projects/:id", h.updateProject) protected.PUT("/projects/:id/env", h.updateProjectEnv) protected.POST("/projects/:id/redeploy", h.redeployProject) protected.GET("/activity", h.getActivity) } } - +func (h *Handler) updateProject(c *gin.Context) { + userID, _ := c.Get("userID") + projectID := c.Param("id") + var req struct { + Name string `json:"name"` + BuildCommand string `json:"build_command"` + StartCommand string `json:"start_command"` + InstallCommand string `json:"install_command"` + Runtime string `json:"runtime"` + GitToken string `json:"git_token"` + RepoURL string `json:"repo_url"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var project models.Project + if result := db.DB.Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + project.Name = req.Name + project.BuildCommand = req.BuildCommand + project.StartCommand = req.StartCommand + project.InstallCommand = req.InstallCommand + project.Runtime = req.Runtime + project.RepoURL = req.RepoURL + if req.GitToken != "" { + project.GitToken = req.GitToken + } + if err := db.DB.Save(&project).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"}) + return + } + c.JSON(http.StatusOK, project) +} func (h *Handler) updateProjectEnv(c *gin.Context) { userID, _ := c.Get("userID") projectID := c.Param("id") - var req struct { EnvVars map[string]string `json:"env_vars"` } @@ -39,20 +75,17 @@ func (h *Handler) updateProjectEnv(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - var project models.Project if result := db.DB.Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } - tx := db.DB.Begin() if err := tx.Where("project_id = ?", project.ID).Delete(&models.EnvVar{}).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update env vars"}) return } - for k, v := range req.EnvVars { envVar := models.EnvVar{ ProjectID: project.ID, @@ -65,99 +98,93 @@ func (h *Handler) updateProjectEnv(c *gin.Context) { return } } - tx.Commit() c.JSON(http.StatusOK, gin.H{"status": "updated"}) } - func (h *Handler) redeployProject(c *gin.Context) { userID, _ := c.Get("userID") projectID := c.Param("id") - + var req struct { + Commit string `json:"commit"` + } + c.ShouldBindJSON(&req) var project models.Project if result := db.DB.Preload("EnvVars").Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } - depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) + initialCommit := "MANUAL" + if req.Commit != "" { + initialCommit = req.Commit + } deployment := models.Deployment{ ID: depID, ProjectID: project.ID, Status: "building", - Commit: "MANUAL", - Logs: "Starting manual redeploy...", + Commit: initialCommit, + Logs: fmt.Sprintf("Starting redeploy for commit %s...", initialCommit), } db.DB.Create(&deployment) - + envMap := make(map[string]string) + for _, env := range project.EnvVars { + envMap[env.Key] = env.Value + } + resolvedEnv, err := h.resolveEnvVars(c.Request.Context(), userID.(string), envMap) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } go func() { var logBuffer bytes.Buffer streamer := &StreamWriter{DeploymentID: deployment.ID} multi := io.MultiWriter(&logBuffer, streamer) - - envMap := make(map[string]string) - for _, env := range project.EnvVars { - envMap[env.Key] = env.Value - } - - imageName, commitHash, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi) + imageName, commitHash, err := h.builder.Build(project.RepoURL, req.Commit, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, resolvedEnv, multi) deployment.Logs = logBuffer.String() - if err != nil { deployment.Status = "failed" deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err) db.DB.Save(&deployment) return } - - // Update commit hash if we got one if commitHash != "" { deployment.Commit = commitHash } - var envStrings []string - for _, env := range project.EnvVars { - envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value)) + for k, v := range resolvedEnv { + envStrings = append(envStrings, fmt.Sprintf("%s=%s", k, v)) } - - containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, project.Name, project.Port, envStrings) + containerID, err := h.deployer.RunContainer(context.Background(), imageName, project.Name, project.Port, envStrings) if err != nil { deployment.Status = "failed" deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err) db.DB.Save(&deployment) return } - deployment.Status = "live" deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port) deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID) db.DB.Save(&deployment) }() - c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID}) } - func (h *Handler) getActivity(c *gin.Context) { userID, _ := c.Get("userID") var deployments []models.Deployment - err := db.DB.Joins("JOIN projects ON projects.id = deployments.project_id"). Where("projects.owner_id = ?", userID). Order("deployments.created_at desc"). Limit(20). Preload("Project"). Find(&deployments).Error - if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch activity"}) return } c.JSON(http.StatusOK, deployments) } - func (h *Handler) createProject(c *gin.Context) { userID, _ := c.Get("userID") - var req struct { DeployRequest EnvVars map[string]string `json:"env_vars"` @@ -166,31 +193,28 @@ func (h *Handler) createProject(c *gin.Context) { InstallCommand string `json:"install_command"` Runtime string `json:"runtime"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - port, err := h.ports.GetPort(req.Name, req.Port) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)}) return } - + resolvedEnv, err := h.resolveEnvVars(c.Request.Context(), userID.(string), req.EnvVars) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } var envVarsModel []models.EnvVar - var envStrings []string for k, v := range req.EnvVars { - envStrings = append(envStrings, fmt.Sprintf("%s=%s", k, v)) envVarsModel = append(envVarsModel, models.EnvVar{Key: k, Value: v}) } - secretBytes := make([]byte, 16) rand.Read(secretBytes) webhookSecret := hex.EncodeToString(secretBytes) - id, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) - project := models.Project{ ID: id, Name: req.Name, @@ -205,12 +229,10 @@ func (h *Handler) createProject(c *gin.Context) { InstallCommand: req.InstallCommand, Runtime: req.Runtime, } - if result := db.DB.Create(&project); result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save project to DB"}) return } - depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) deployment := models.Deployment{ ID: depID, @@ -220,45 +242,71 @@ func (h *Handler) createProject(c *gin.Context) { Logs: "Starting build...", } db.DB.Create(&deployment) - go func() { var logBuffer bytes.Buffer streamer := &StreamWriter{DeploymentID: deployment.ID} multi := io.MultiWriter(&logBuffer, streamer) - - imageName, commitHash, err := h.builder.Build(req.Repo, req.Name, req.GitToken, req.BuildCommand, req.StartCommand, req.InstallCommand, req.Runtime, req.EnvVars, multi) + imageName, commitHash, err := h.builder.Build(req.Repo, "", req.Name, req.GitToken, req.BuildCommand, req.StartCommand, req.InstallCommand, req.Runtime, resolvedEnv, multi) deployment.Logs = logBuffer.String() - if err != nil { deployment.Status = "failed" deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err) db.DB.Save(&deployment) return } - - // Update commit hash if commitHash != "" { deployment.Commit = commitHash } - - containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, envStrings) + var envStrings []string + for k, v := range resolvedEnv { + envStrings = append(envStrings, fmt.Sprintf("%s=%s", k, v)) + } + containerID, err := h.deployer.RunContainer(context.Background(), imageName, req.Name, port, envStrings) if err != nil { deployment.Status = "failed" deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err) db.DB.Save(&deployment) return } - deployment.Status = "live" deployment.URL = fmt.Sprintf("http://localhost:%d", port) deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID) db.DB.Save(&deployment) }() - project.Deployments = []models.Deployment{deployment} c.JSON(http.StatusOK, project) } - +func (h *Handler) resolveEnvVars(ctx context.Context, userID string, envVars map[string]string) (map[string]string, error) { + resolved := make(map[string]string) + for k, v := range envVars { + if strings.HasPrefix(v, "$DB:") { + dbName := strings.TrimPrefix(v, "$DB:") + var database models.Database + if err := db.DB.Where("name = ? AND owner_id = ?", dbName, userID).First(&database).Error; err != nil { + return nil, fmt.Errorf("database '%s' not found", dbName) + } + if database.Type != "mongodb" { + return nil, fmt.Errorf("database '%s' is not a mongodb instance (auto-resolution only supported for mongo)", dbName) + } + env, err := h.deployer.GetContainerEnv(ctx, database.ContainerID) + if err != nil { + return nil, fmt.Errorf("failed to get env for database '%s': %v", dbName, err) + } + var user, pass string + for _, e := range env { + if len(e) > 25 && e[:25] == "MONGO_INITDB_ROOT_USERNAME=" { + user = e[25:] + } else if len(e) > 25 && e[:25] == "MONGO_INITDB_ROOT_PASSWORD=" { + pass = e[25:] + } + } + resolved[k] = fmt.Sprintf("mongodb://%s:%s@172.17.0.1:%d/?authSource=admin", user, pass, database.Port) + } else { + resolved[k] = v + } + } + return resolved, nil +} func (h *Handler) listProjects(c *gin.Context) { userID, _ := c.Get("userID") var projects []models.Project @@ -268,11 +316,9 @@ func (h *Handler) listProjects(c *gin.Context) { } c.JSON(http.StatusOK, projects) } - func (h *Handler) getProject(c *gin.Context) { userID, _ := c.Get("userID") projectID := c.Param("id") - var project models.Project if result := db.DB.Order("created_at desc").Preload("Deployments", func(db *gorm.DB) *gorm.DB { return db.Order("deployments.created_at desc") diff --git a/backend/internal/api/storage.go b/backend/internal/api/storage.go index acfdb55..b625673 100644 --- a/backend/internal/api/storage.go +++ b/backend/internal/api/storage.go @@ -1,23 +1,23 @@ package api import ( + "clickploy/internal/db" + "clickploy/internal/models" "fmt" "net/http" "os" "path/filepath" "syscall" - "clickploy/internal/db" - "clickploy/internal/models" - "github.com/gin-gonic/gin" + gonanoid "github.com/matoous/go-nanoid/v2" ) type CreateDatabaseRequest struct { Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required,oneof=sqlite"` + Type string `json:"type" binding:"required,oneof=sqlite mongodb"` + Port int `json:"port"` } - type StorageStatsResponse struct { TotalMB float64 `json:"total_mb"` UsedMB float64 `json:"used_mb"` @@ -30,28 +30,67 @@ func (h *Handler) RegisterStorageRoutes(r *gin.Engine) { api.GET("/stats", h.handleGetStorageStats) api.GET("/databases", h.handleListDatabases) api.POST("/databases", h.handleCreateDatabase) + api.PUT("/databases/:id", h.handleUpdateDatabase) + api.DELETE("/databases/:id", h.handleDeleteDatabase) + api.GET("/databases/:id/credentials", h.handleGetDatabaseCredentials) + api.PUT("/databases/:id/credentials", h.handleUpdateDatabaseCredentials) + api.POST("/databases/:id/stop", h.handleStopDatabase) + api.POST("/databases/:id/restart", h.handleRestartDatabase) } } - +func (h *Handler) handleUpdateDatabase(c *gin.Context) { + userId := c.GetString("userID") + dbId := c.Param("id") + var req struct { + Port int `json:"port"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var database models.Database + if err := db.DB.Where("id = ? AND owner_id = ?", dbId, userId).First(&database).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) + return + } + if database.Type == "mongodb" && req.Port != 0 && req.Port != database.Port { + envVars, err := h.deployer.GetContainerEnv(c.Request.Context(), database.ContainerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve current container configuration"}) + return + } + wd, _ := os.Getwd() + volumePath := filepath.Join(wd, "data", "volumes", fmt.Sprintf("%s_%s", userId, database.Name)) + containerName := fmt.Sprintf("mongo_%s_%s", userId, database.Name) + newId, err := h.deployer.StartDatabaseContainer(c.Request.Context(), "mongo:latest", containerName, req.Port, volumePath, envVars) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restart container: %v", err)}) + return + } + database.Port = req.Port + database.ContainerID = newId + if err := db.DB.Save(&database).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database record"}) + return + } + } + c.JSON(http.StatusOK, database) +} func (h *Handler) handleGetStorageStats(c *gin.Context) { var stat syscall.Statfs_t wd, _ := os.Getwd() - if err := syscall.Statfs(wd, &stat); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get disk stats"}) return } - totalBytes := stat.Blocks * uint64(stat.Bsize) availBytes := stat.Bavail * uint64(stat.Bsize) usedBytes := totalBytes - availBytes - c.JSON(http.StatusOK, StorageStatsResponse{ TotalMB: float64(totalBytes) / 1024 / 1024, UsedMB: float64(usedBytes) / 1024 / 1024, }) } - func (h *Handler) handleListDatabases(c *gin.Context) { userId := c.GetString("userID") var dbs []models.Database @@ -61,7 +100,6 @@ func (h *Handler) handleListDatabases(c *gin.Context) { } c.JSON(http.StatusOK, dbs) } - func (h *Handler) handleCreateDatabase(c *gin.Context) { userId := c.GetString("userID") var req CreateDatabaseRequest @@ -69,7 +107,6 @@ func (h *Handler) handleCreateDatabase(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - newDB := models.Database{ Name: req.Name, Type: req.Type, @@ -77,22 +114,257 @@ func (h *Handler) handleCreateDatabase(c *gin.Context) { OwnerID: userId, SizeMB: 0, } - - dataDir := "./data/user_dbs" - os.MkdirAll(dataDir, 0755) - - dbPath := filepath.Join(dataDir, fmt.Sprintf("%s_%s.db", userId, req.Name)) - file, err := os.Create(dbPath) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database file"}) + if req.Type == "sqlite" { + dataDir := "./data/user_dbs" + os.MkdirAll(dataDir, 0755) + dbPath := filepath.Join(dataDir, fmt.Sprintf("%s_%s.db", userId, req.Name)) + file, err := os.Create(dbPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database file"}) + return + } + file.Close() + } else if req.Type == "mongodb" { + port := 27017 + if req.Port != 0 { + p, err := h.ports.GetPort(req.Name, req.Port) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port %d is not available", req.Port)}) + return + } + port = p + } else { + p, err := h.ports.GetPort(req.Name, 27017) + if err == nil { + port = p + } else { + p, err := h.ports.GetPort(req.Name, 0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to allocate port"}) + return + } + port = p + } + } + newDB.Port = port + username := "root" + password, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 16) + wd, _ := os.Getwd() + volumePath := filepath.Join(wd, "data", "volumes", fmt.Sprintf("%s_%s", userId, req.Name)) + os.MkdirAll(volumePath, 0755) + containerName := fmt.Sprintf("mongo_%s_%s", userId, req.Name) + envVars := []string{ + fmt.Sprintf("MONGO_INITDB_ROOT_USERNAME=%s", username), + fmt.Sprintf("MONGO_INITDB_ROOT_PASSWORD=%s", password), + } + id, err := h.deployer.StartDatabaseContainer(c.Request.Context(), "mongo:latest", containerName, port, volumePath, envVars) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to start database container: %v", err)}) + return + } + newDB.ContainerID = id + newDB.Status = "running" + c.JSON(http.StatusOK, gin.H{ + "database": newDB, + "username": username, + "password": password, + "uri": fmt.Sprintf("mongodb://%s:%s@localhost:%d/?authSource=admin", username, password, port), + }) + if err := db.DB.Create(&newDB).Error; err != nil { + fmt.Printf("Failed to save DB record: %v\n", err) + } return } - file.Close() - if err := db.DB.Create(&newDB).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save database record"}) return } - c.JSON(http.StatusOK, newDB) } +func (h *Handler) handleDeleteDatabase(c *gin.Context) { + userId := c.GetString("userID") + dbId := c.Param("id") + var database models.Database + if err := db.DB.Where("id = ? AND owner_id = ?", dbId, userId).First(&database).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) + return + } + if database.Type == "sqlite" { + dataDir := "./data/user_dbs" + dbPath := filepath.Join(dataDir, fmt.Sprintf("%s_%s.db", userId, database.Name)) + if err := os.Remove(dbPath); err != nil && !os.IsNotExist(err) { + fmt.Printf("Failed to delete database file: %v\n", err) + } + } else if database.Type == "mongodb" { + if database.ContainerID != "" { + if err := h.deployer.RemoveContainer(c.Request.Context(), database.ContainerID); err != nil { + fmt.Printf("Failed to remove container: %v\n", err) + } + } + wd, _ := os.Getwd() + volumePath := filepath.Join(wd, "data", "volumes", fmt.Sprintf("%s_%s", userId, database.Name)) + os.RemoveAll(volumePath) + } + if err := db.DB.Delete(&database).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete database record"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} +func (h *Handler) handleGetDatabaseCredentials(c *gin.Context) { + userId := c.GetString("userID") + dbId := c.Param("id") + var database models.Database + if err := db.DB.Where("id = ? AND owner_id = ?", dbId, userId).First(&database).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) + return + } + if database.Type != "mongodb" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Credential management only supported for MongoDB"}) + return + } + envVars, err := h.deployer.GetContainerEnv(c.Request.Context(), database.ContainerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve container configuration"}) + return + } + var username, password string + for _, env := range envVars { + if len(env) > 25 && env[:25] == "MONGO_INITDB_ROOT_USERNAME=" { + username = env[25:] + } else if len(env) > 25 && env[:25] == "MONGO_INITDB_ROOT_PASSWORD=" { + password = env[25:] + } + } + uri := fmt.Sprintf("mongodb://%s:%s@localhost:%d/?authSource=admin", username, password, database.Port) + publicUri := fmt.Sprintf("mongodb://%s:%s@:%d/?authSource=admin", username, password, database.Port) + c.JSON(http.StatusOK, gin.H{ + "username": username, + "password": password, + "uri": uri, + "public_uri": publicUri, + }) +} +func (h *Handler) handleUpdateDatabaseCredentials(c *gin.Context) { + userId := c.GetString("userID") + dbId := c.Param("id") + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var database models.Database + if err := db.DB.Where("id = ? AND owner_id = ?", dbId, userId).First(&database).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) + return + } + if database.Type != "mongodb" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Credential management only supported for MongoDB"}) + return + } + envVars, err := h.deployer.GetContainerEnv(c.Request.Context(), database.ContainerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve container configuration"}) + return + } + var currentUser, currentPass string + var otherEnv []string + for _, env := range envVars { + if len(env) > 25 && env[:25] == "MONGO_INITDB_ROOT_USERNAME=" { + currentUser = env[25:] + } else if len(env) > 25 && env[:25] == "MONGO_INITDB_ROOT_PASSWORD=" { + currentPass = env[25:] + } else { + otherEnv = append(otherEnv, env) + } + } + if currentUser == "" || currentPass == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not determine current credentials from container"}) + return + } + ctx := c.Request.Context() + needsRestart := false + if req.Username != currentUser { + createCmd := fmt.Sprintf("db.getSiblingDB('admin').createUser({user: '%s', pwd: '%s', roles: ['root']})", req.Username, req.Password) + if err := h.deployer.ExecContainer(ctx, database.ContainerID, []string{"mongosh", "admin", "-u", currentUser, "-p", currentPass, "--eval", createCmd}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create new user: %v", err)}) + return + } + dropCmd := fmt.Sprintf("db.getSiblingDB('admin').dropUser('%s')", currentUser) + if err := h.deployer.ExecContainer(ctx, database.ContainerID, []string{"mongosh", "admin", "-u", req.Username, "-p", req.Password, "--eval", dropCmd}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to drop old user: %v", err)}) + return + } + needsRestart = true + } else if req.Password != currentPass { + changeCmd := fmt.Sprintf("db.getSiblingDB('admin').changeUserPassword('%s', '%s')", currentUser, req.Password) + if err := h.deployer.ExecContainer(ctx, database.ContainerID, []string{"mongosh", "admin", "-u", currentUser, "-p", currentPass, "--eval", changeCmd}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to change password: %v", err)}) + return + } + needsRestart = true + } + if needsRestart { + newEnv := append(otherEnv, + fmt.Sprintf("MONGO_INITDB_ROOT_USERNAME=%s", req.Username), + fmt.Sprintf("MONGO_INITDB_ROOT_PASSWORD=%s", req.Password), + ) + wd, _ := os.Getwd() + volumePath := filepath.Join(wd, "data", "volumes", fmt.Sprintf("%s_%s", userId, database.Name)) + containerName := fmt.Sprintf("mongo_%s_%s", userId, database.Name) + newId, err := h.deployer.StartDatabaseContainer(ctx, "mongo:latest", containerName, database.Port, volumePath, newEnv) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restart container with new creds: %v", err)}) + return + } + database.ContainerID = newId + if err := db.DB.Save(&database).Error; err != nil { + } + } + c.JSON(http.StatusOK, gin.H{"status": "updated"}) +} +func (h *Handler) handleStopDatabase(c *gin.Context) { + userId := c.GetString("userID") + dbId := c.Param("id") + var database models.Database + if err := db.DB.Where("id = ? AND owner_id = ?", dbId, userId).First(&database).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) + return + } + if database.Type == "mongodb" && database.ContainerID != "" { + if err := h.deployer.StopContainer(c.Request.Context(), database.ContainerID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to stop container: %v", err)}) + return + } + database.Status = "stopped" + if err := db.DB.Save(&database).Error; err != nil { + fmt.Printf("Failed to update db status: %v\n", err) + } + } + c.JSON(http.StatusOK, gin.H{"status": "stopped"}) +} +func (h *Handler) handleRestartDatabase(c *gin.Context) { + userId := c.GetString("userID") + dbId := c.Param("id") + var database models.Database + if err := db.DB.Where("id = ? AND owner_id = ?", dbId, userId).First(&database).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) + return + } + if database.Type == "mongodb" && database.ContainerID != "" { + if err := h.deployer.RestartContainer(c.Request.Context(), database.ContainerID); err != nil { + if err := h.deployer.StartContainer(c.Request.Context(), database.ContainerID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restart container: %v", err)}) + return + } + } + database.Status = "running" + if err := db.DB.Save(&database).Error; err != nil { + fmt.Printf("Failed to update db status: %v\n", err) + } + } + c.JSON(http.StatusOK, gin.H{"status": "restarted"}) +} diff --git a/backend/internal/api/stream.go b/backend/internal/api/stream.go index 11c2298..5f41e7a 100644 --- a/backend/internal/api/stream.go +++ b/backend/internal/api/stream.go @@ -1,27 +1,21 @@ package api - import ( "net/http" "sync" "time" - "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) - var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } - type LogHub struct { mu sync.Mutex streams map[string][]chan []byte } - var Hub = &LogHub{ streams: make(map[string][]chan []byte), } - func (h *LogHub) Broadcast(deploymentID string, p []byte) { h.mu.Lock() defer h.mu.Unlock() @@ -34,7 +28,6 @@ func (h *LogHub) Broadcast(deploymentID string, p []byte) { } } } - func (h *LogHub) Subscribe(deploymentID string) chan []byte { h.mu.Lock() defer h.mu.Unlock() @@ -42,7 +35,6 @@ func (h *LogHub) Subscribe(deploymentID string) chan []byte { h.streams[deploymentID] = append(h.streams[deploymentID], ch) return ch } - func (h *LogHub) Unsubscribe(deploymentID string, ch chan []byte) { h.mu.Lock() defer h.mu.Unlock() @@ -59,34 +51,28 @@ func (h *LogHub) Unsubscribe(deploymentID string, ch chan []byte) { } } } - type StreamWriter struct { DeploymentID string } - func (w *StreamWriter) Write(p []byte) (n int, err error) { c := make([]byte, len(p)) copy(c, p) Hub.Broadcast(w.DeploymentID, c) return len(p), nil } - func (h *Handler) streamDeploymentLogs(c *gin.Context) { deploymentID := c.Param("id") if deploymentID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"}) return } - conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } defer conn.Close() - logChan := Hub.Subscribe(deploymentID) defer Hub.Unsubscribe(deploymentID, logChan) - go func() { for { if _, _, err := conn.NextReader(); err != nil { @@ -95,7 +81,6 @@ func (h *Handler) streamDeploymentLogs(c *gin.Context) { } } }() - for logChunk := range logChan { conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := conn.WriteMessage(websocket.TextMessage, logChunk); err != nil { @@ -103,7 +88,6 @@ func (h *Handler) streamDeploymentLogs(c *gin.Context) { } } } - func (h *Handler) RegisterStreamRoutes(r *gin.Engine) { r.GET("/api/deployments/:id/logs/stream", h.streamDeploymentLogs) } diff --git a/backend/internal/api/webhook.go b/backend/internal/api/webhook.go index afafed9..020aee7 100644 --- a/backend/internal/api/webhook.go +++ b/backend/internal/api/webhook.go @@ -2,13 +2,12 @@ package api import ( "bytes" + "clickploy/internal/db" + "clickploy/internal/models" "fmt" "io" "net/http" - "clickploy/internal/db" - "clickploy/internal/models" - "github.com/gin-gonic/gin" gonanoid "github.com/matoous/go-nanoid/v2" ) @@ -16,27 +15,22 @@ import ( func (h *Handler) RegisterWebhookRoutes(r *gin.Engine) { r.POST("/projects/:projectID/webhook/:webhookID", h.handleWebhook) } - func (h *Handler) handleWebhook(c *gin.Context) { projectID := c.Param("projectID") webhookSecret := c.Param("webhookID") - if projectID == "" || webhookSecret == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook url"}) return } - var project models.Project if result := db.DB.Preload("EnvVars").Where("id = ?", projectID).First(&project); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } - if project.WebhookSecret != webhookSecret { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Webhook Secret"}) return } - depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) deployment := models.Deployment{ ID: depID, @@ -49,18 +43,15 @@ func (h *Handler) handleWebhook(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment record"}) return } - go func() { var logBuffer bytes.Buffer streamer := &StreamWriter{DeploymentID: deployment.ID} multi := io.MultiWriter(&logBuffer, streamer) - envMap := make(map[string]string) for _, env := range project.EnvVars { envMap[env.Key] = env.Value } - - imageName, commitHash, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi) + imageName, commitHash, err := h.builder.Build(project.RepoURL, "", project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi) deployment.Logs = logBuffer.String() if err != nil { deployment.Status = "failed" @@ -68,16 +59,13 @@ func (h *Handler) handleWebhook(c *gin.Context) { db.DB.Save(&deployment) return } - if commitHash != "" { deployment.Commit = commitHash } - var envStrings []string for _, env := range project.EnvVars { envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value)) } - containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, project.Name, project.Port, envStrings) if err != nil { deployment.Status = "failed" @@ -85,12 +73,10 @@ func (h *Handler) handleWebhook(c *gin.Context) { db.DB.Save(&deployment) return } - deployment.Status = "live" deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port) deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID) db.DB.Save(&deployment) }() - c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID}) } diff --git a/backend/internal/auth/utils.go b/backend/internal/auth/utils.go index b37683f..a1aee73 100644 --- a/backend/internal/auth/utils.go +++ b/backend/internal/auth/utils.go @@ -1,31 +1,24 @@ package auth - import ( "errors" "time" - "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) - var SecretKey = []byte("super-secret-key-change-me") - type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` jwt.RegisteredClaims } - func HashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) return string(bytes), err } - func CheckPassword(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } - func GenerateToken(userID string, email string) (string, error) { expirationTime := time.Now().Add(24 * time.Hour) claims := &Claims{ @@ -38,7 +31,6 @@ func GenerateToken(userID string, email string) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(SecretKey) } - func ValidateToken(tokenString string) (*Claims, error) { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { diff --git a/backend/internal/builder/builder.go b/backend/internal/builder/builder.go index 25a7e1d..88b8cc8 100644 --- a/backend/internal/builder/builder.go +++ b/backend/internal/builder/builder.go @@ -1,5 +1,4 @@ package builder - import ( "fmt" "io" @@ -9,14 +8,11 @@ import ( "path/filepath" "strings" ) - type Builder struct{} - func NewBuilder() *Builder { return &Builder{} } - -func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installCmd, runtime string, envVars map[string]string, logWriter io.Writer) (string, string, error) { +func (b *Builder) Build(repoURL, targetCommit, appName, gitToken, buildCmd, startCmd, installCmd, runtime string, envVars map[string]string, logWriter io.Writer) (string, string, error) { workDir := filepath.Join("/tmp", "paas-builds", appName) if err := os.RemoveAll(workDir); err != nil { return "", "", fmt.Errorf("failed to clean work dir: %w", err) @@ -24,7 +20,6 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC if err := os.MkdirAll(workDir, 0755); err != nil { return "", "", fmt.Errorf("failed to create work dir: %w", err) } - cloneURL := repoURL if gitToken != "" { u, err := url.Parse(repoURL) @@ -34,17 +29,29 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC u.User = url.UserPassword("oauth2", gitToken) cloneURL = u.String() } - fmt.Fprintf(logWriter, ">>> Cloning repository %s...\n", repoURL) - cloneCmd := exec.Command("git", "clone", "--depth", "1", cloneURL, ".") + var cloneCmd *exec.Cmd + if targetCommit != "" && targetCommit != "HEAD" && targetCommit != "MANUAL" && targetCommit != "WEBHOOK" { + cloneCmd = exec.Command("git", "clone", "--filter=blob:none", cloneURL, ".") + } else { + cloneCmd = exec.Command("git", "clone", "--depth", "1", cloneURL, ".") + } cloneCmd.Dir = workDir cloneCmd.Stdout = logWriter cloneCmd.Stderr = logWriter if err := cloneCmd.Run(); err != nil { return "", "", fmt.Errorf("git clone failed: %w", err) } - - // Get commit hash + if targetCommit != "" && targetCommit != "HEAD" && targetCommit != "MANUAL" && targetCommit != "WEBHOOK" { + fmt.Fprintf(logWriter, ">>> Checking out commit %s...\n", targetCommit) + checkoutCmd := exec.Command("git", "checkout", targetCommit) + checkoutCmd.Dir = workDir + checkoutCmd.Stdout = logWriter + checkoutCmd.Stderr = logWriter + if err := checkoutCmd.Run(); err != nil { + return "", "", fmt.Errorf("git checkout failed: %w", err) + } + } commitCmd := exec.Command("git", "rev-parse", "HEAD") commitCmd.Dir = workDir commitHashBytes, err := commitCmd.Output() @@ -55,14 +62,11 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC } else { fmt.Fprintf(logWriter, ">>> Failed to get commit hash: %v\n", err) } - if runtime == "" { runtime = "nodejs" } - var nixPkgs string var defaultInstall, defaultBuild, defaultStart string - switch runtime { case "bun": nixPkgs = `["bun"]` @@ -85,66 +89,50 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC defaultBuild = "npm run build" defaultStart = "npm run start" } - installStr := defaultInstall if installCmd != "" { installStr = installCmd } - buildStr := defaultBuild if buildCmd != "" { buildStr = buildCmd } - startStr := defaultStart if startCmd != "" { startStr = startCmd } - nixpacksConfig := fmt.Sprintf(` [phases.setup] nixPkgs = %s - [phases.install] cmds = ["%s"] - [phases.build] cmds = ["%s"] - [start] cmd = "%s" `, nixPkgs, installStr, buildStr, startStr) - if _, err := os.Stat(filepath.Join(workDir, "package.json")); err == nil { configPath := filepath.Join(workDir, "nixpacks.toml") if err := os.WriteFile(configPath, []byte(nixpacksConfig), 0644); err != nil { return "", "", fmt.Errorf("failed to write nixpacks.toml: %w", err) } } - imageName := strings.ToLower(appName) - fmt.Fprintf(logWriter, "\n>>> Starting Nixpacks build for %s...\n", imageName) - args := []string{"build", ".", "--name", imageName, "--no-cache"} for k, v := range envVars { args = append(args, "--env", fmt.Sprintf("%s=%s", k, v)) } - nixCmd := exec.Command("nixpacks", args...) nixCmd.Dir = workDir nixCmd.Stdout = logWriter nixCmd.Stderr = logWriter - nixCmd.Env = append(os.Environ(), "NIXPACKS_NO_CACHE=1", ) - if err := nixCmd.Run(); err != nil { return "", "", fmt.Errorf("nixpacks build failed: %w", err) } - fmt.Fprintf(logWriter, "\n>>> Build successful!\n") - return imageName, commitHash, nil } diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 4e9a0c8..ce05f06 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -1,29 +1,22 @@ package db - import ( "log" "path/filepath" - "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" - "clickploy/internal/models" ) - var DB *gorm.DB - func Init(storagePath string) { var err error dbPath := filepath.Join(storagePath, "clickploy.db") - DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), }) if err != nil { log.Fatal("Failed to connect to database:", err) } - log.Println("Migrating database...") err = DB.AutoMigrate(&models.User{}, &models.Project{}, &models.Deployment{}, &models.EnvVar{}, &models.Database{}) if err != nil { diff --git a/backend/internal/deployer/deployer.go b/backend/internal/deployer/deployer.go index 3300f26..b6ec60d 100644 --- a/backend/internal/deployer/deployer.go +++ b/backend/internal/deployer/deployer.go @@ -1,28 +1,26 @@ package deployer - import ( "context" "fmt" "io" - "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" ) - type Deployer struct { cli *client.Client } - +func (d *Deployer) RemoveContainer(ctx context.Context, containerID string) error { + return d.cli.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{Force: true}) +} func NewDeployer() (*Deployer, error) { - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithVersion("1.44")) if err != nil { return nil, fmt.Errorf("failed to create docker client: %w", err) } return &Deployer{cli: cli}, nil } - func (d *Deployer) RunContainer(ctx context.Context, imageName, appName string, hostPort int, envVars []string) (string, error) { config := &container.Config{ Image: imageName, @@ -31,7 +29,6 @@ func (d *Deployer) RunContainer(ctx context.Context, imageName, appName string, }, Env: envVars, } - hostConfig := &container.HostConfig{ PortBindings: nat.PortMap{ "3000/tcp": []nat.PortBinding{ @@ -45,21 +42,96 @@ func (d *Deployer) RunContainer(ctx context.Context, imageName, appName string, Name: "unless-stopped", }, } - _ = d.cli.ContainerRemove(ctx, appName, types.ContainerRemoveOptions{Force: true}) - resp, err := d.cli.ContainerCreate(ctx, config, hostConfig, nil, nil, appName) if err != nil { return "", fmt.Errorf("failed to create container: %w", err) } - if err := d.cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil { return "", fmt.Errorf("failed to start container: %w", err) } - return resp.ID, nil } - +func (d *Deployer) GetContainerEnv(ctx context.Context, containerID string) ([]string, error) { + info, err := d.cli.ContainerInspect(ctx, containerID) + if err != nil { + return nil, err + } + return info.Config.Env, nil +} func (d *Deployer) StreamLogs(ctx context.Context, containerID string) (io.ReadCloser, error) { return d.cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true}) } +func (d *Deployer) StartDatabaseContainer(ctx context.Context, image, name string, port int, volumePath string, envVars []string) (string, error) { + _, err := d.cli.ImagePull(ctx, image, types.ImagePullOptions{}) + if err != nil { + fmt.Printf("Failed to pull image %s: %v\n", image, err) + } + config := &container.Config{ + Image: image, + ExposedPorts: nat.PortSet{ + "27017/tcp": struct{}{}, + }, + Env: envVars, + } + hostConfig := &container.HostConfig{ + PortBindings: nat.PortMap{ + "27017/tcp": []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: fmt.Sprintf("%d", port), + }, + }, + }, + Binds: []string{ + fmt.Sprintf("%s:/data/db", volumePath), + }, + RestartPolicy: container.RestartPolicy{ + Name: "unless-stopped", + }, + } + d.cli.ContainerRemove(ctx, name, types.ContainerRemoveOptions{Force: true}) + resp, err := d.cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name) + if err != nil { + return "", fmt.Errorf("failed to create db container: %w", err) + } + if err := d.cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil { + return "", fmt.Errorf("failed to start db container: %w", err) + } + return resp.ID, nil +} +func (d *Deployer) ExecContainer(ctx context.Context, containerID string, cmd []string) error { + execConfig := types.ExecConfig{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + } + resp, err := d.cli.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + return fmt.Errorf("failed to create exec: %w", err) + } + execID := resp.ID + attachResp, err := d.cli.ContainerExecAttach(ctx, execID, types.ExecStartCheck{}) + if err != nil { + return fmt.Errorf("failed to attach exec: %w", err) + } + defer attachResp.Close() + io.Copy(io.Discard, attachResp.Reader) + inspectResp, err := d.cli.ContainerExecInspect(ctx, execID) + if err != nil { + return fmt.Errorf("failed to inspect exec: %w", err) + } + if inspectResp.ExitCode != 0 { + return fmt.Errorf("command exited with code %d", inspectResp.ExitCode) + } + return nil +} +func (d *Deployer) StopContainer(ctx context.Context, containerID string) error { + return d.cli.ContainerStop(ctx, containerID, container.StopOptions{}) +} +func (d *Deployer) StartContainer(ctx context.Context, containerID string) error { + return d.cli.ContainerStart(ctx, containerID, types.ContainerStartOptions{}) +} +func (d *Deployer) RestartContainer(ctx context.Context, containerID string) error { + return d.cli.ContainerRestart(ctx, containerID, container.StopOptions{}) +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 57f13e0..4c9c756 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -1,11 +1,8 @@ package models - import ( "time" - "gorm.io/gorm" ) - type User struct { ID string `gorm:"primaryKey" json:"id"` CreatedAt time.Time `json:"created_at"` @@ -16,9 +13,9 @@ type User struct { Name string `json:"name"` Avatar string `json:"avatar"` IsAdmin bool `json:"is_admin"` + APIKey string `json:"api_key" gorm:"uniqueIndex"` Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"` } - type Project struct { ID string `gorm:"primaryKey" json:"id"` CreatedAt time.Time `json:"created_at"` @@ -37,14 +34,12 @@ type Project struct { Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"` EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"` } - type EnvVar struct { gorm.Model ProjectID string `json:"project_id"` Key string `json:"key"` Value string `json:"value"` } - type Deployment struct { ID string `gorm:"primaryKey" json:"id"` CreatedAt time.Time `json:"created_at"` @@ -57,12 +52,13 @@ type Deployment struct { Logs string `json:"logs"` URL string `json:"url"` } - type Database struct { gorm.Model - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - OwnerID string `json:"owner_id"` - SizeMB float64 `json:"size_mb"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + OwnerID string `json:"owner_id"` + SizeMB float64 `json:"size_mb"` + Port int `json:"port"` + ContainerID string `json:"container_id"` } diff --git a/backend/internal/ports/ports.go b/backend/internal/ports/ports.go index f93af92..1b167a2 100644 --- a/backend/internal/ports/ports.go +++ b/backend/internal/ports/ports.go @@ -1,18 +1,16 @@ package ports - import ( "fmt" + "math/rand" "net" "sync" ) - type Manager struct { mu sync.Mutex startPort int endPort int allocations map[string]int } - func NewManager(start, end int) *Manager { return &Manager{ startPort: start, @@ -20,18 +18,15 @@ func NewManager(start, end int) *Manager { allocations: make(map[string]int), } } - func (m *Manager) GetPort(appName string, specificPort int) (int, error) { m.mu.Lock() defer m.mu.Unlock() - if port, exists := m.allocations[appName]; exists { if specificPort > 0 && specificPort != port { return 0, fmt.Errorf("app %s is already running on port %d", appName, port) } return port, nil } - if specificPort > 0 { if err := m.checkPortAvailable(specificPort); err != nil { return 0, err @@ -39,8 +34,10 @@ func (m *Manager) GetPort(appName string, specificPort int) (int, error) { m.allocations[appName] = specificPort return specificPort, nil } - - for port := m.startPort; port <= m.endPort; port++ { + rangeSize := m.endPort - m.startPort + 1 + offsets := rand.Perm(rangeSize) + for _, offset := range offsets { + port := m.startPort + offset if err := m.checkPortAvailable(port); err == nil { if !m.isPortAllocatedInternal(port) { m.allocations[appName] = port @@ -48,10 +45,8 @@ func (m *Manager) GetPort(appName string, specificPort int) (int, error) { } } } - return 0, fmt.Errorf("no available ports in range %d-%d", m.startPort, m.endPort) } - func (m *Manager) isPortAllocatedInternal(port int) bool { for _, p := range m.allocations { if p == port { @@ -60,16 +55,13 @@ func (m *Manager) isPortAllocatedInternal(port int) bool { } return false } - func (m *Manager) checkPortAvailable(port int) error { if port < m.startPort || port > m.endPort { return fmt.Errorf("port %d is out of allowed range %d-%d", port, m.startPort, m.endPort) } - if m.isPortAllocatedInternal(port) { return fmt.Errorf("port %d is internally allocated", port) } - addr := fmt.Sprintf("localhost:%d", port) conn, err := net.Dial("tcp", addr) if err != nil { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6ab9d28..92677ce 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,11 +5,11 @@ import { token } from './auth'; const API_BASE = "http://localhost:8080"; export interface DeployResponse { - status: string; - app_name: string; - port: number; - url: string; - message: string; + status: string; + app_name: string; + port: number; + url: string; + message: string; } export interface AuthResponse { @@ -33,6 +33,20 @@ export interface Deployment { updated_at: string; } +export interface Database { + ID: number; + CreatedAt: string; + UpdatedAt: string; + DeletedAt: string | null; + name: string; + type: string; + status: string; + owner_id: string; + size_mb: number; + container_id: string; + port: number; +} + export interface Project { id: string; name: string; @@ -43,6 +57,9 @@ export interface Project { webhook_secret: string; git_token?: string; runtime?: string; + build_command?: string; + start_command?: string; + install_command?: string; } export async function getProject(id: string): Promise { @@ -126,6 +143,18 @@ export async function createProject( } } +export async function updateProject(id: string, data: Partial): Promise { + try { + return await fetchWithAuth(`/api/projects/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); + } catch (e: any) { + toast.error(e.message); + return null; + } +} + export async function updateProjectEnv(id: string, envVars: Record): Promise { try { await fetchWithAuth(`/api/projects/${id}/env`, { @@ -139,10 +168,11 @@ export async function updateProjectEnv(id: string, envVars: Record { +export async function redeployProject(id: string, commit?: string): Promise { try { await fetchWithAuth(`/api/projects/${id}/redeploy`, { method: "POST", + body: JSON.stringify({ commit }), }); return true; } catch (e: any) { @@ -240,6 +270,18 @@ export async function createDatabase(name: string, type: string = "sqlite") { } } +export async function deleteDatabase(id: string) { + try { + await fetchWithAuth(`/api/storage/databases/${id}`, { + method: "DELETE", + }); + return true; + } catch (e: any) { + toast.error(e.message); + return false; + } +} + export async function getAdminUsers() { try { return await fetchWithAuth("/api/admin/users"); @@ -269,3 +311,78 @@ export async function getAdminStats() { return null; } } + +export async function getProfile() { + try { + return await fetchWithAuth("/api/user/"); + } catch (e: any) { + console.error(e); + return null; + } +} + +export async function regenerateAPIKey() { + try { + return await fetchWithAuth("/api/user/key", { + method: "POST", + }); + } catch (e: any) { + toast.error(e.message); + return null; + } +} + +export async function getDatabaseCredentials(id: string) { + try { + return await fetchWithAuth(`/api/storage/databases/${id}/credentials`); + } catch (e: any) { + console.error(e); + return null; + } +} + +export async function updateDatabaseCredentials(id: string, username: string, password: string) { + try { + return await fetchWithAuth(`/api/storage/databases/${id}/credentials`, { + method: "PUT", + body: JSON.stringify({ username, password }), + }); + } catch (e: any) { + toast.error(e.message); + return null; + } +} + +export async function updateDatabase(id: string, port: number) { + try { + return await fetchWithAuth(`/api/storage/databases/${id}`, { + method: "PUT", + body: JSON.stringify({ port }), + }); + } catch (e: any) { + toast.error(e.message); + return null; + } +} + +export async function stopDatabase(id: string) { + try { + return await fetchWithAuth(`/api/storage/databases/${id}/stop`, { + method: "POST", + }); + } catch (e: any) { + toast.error(e.message); + return null; + } +} + +export async function restartDatabase(id: string) { + try { + return await fetchWithAuth(`/api/storage/databases/${id}/restart`, { + method: "POST", + }); + } catch (e: any) { + toast.error(e.message); + return null; + } +} diff --git a/frontend/src/lib/components/ui/tabs/index.ts b/frontend/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..12d4327 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/frontend/src/lib/components/ui/tabs/tabs-content.svelte b/frontend/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..340d65c --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-list.svelte b/frontend/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..08932b6 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..e623b36 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs.svelte b/frontend/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..ef6cada --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6086bad..7407ff2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -539,7 +539,7 @@ - {new URL(project.repo_url).pathname.slice(1)} + {new URL(project.repo_url).pathname.slice(1)} {latestDeployment ? new Date( - latestDeployment.CreatedAt, + latestDeployment.created_at, ).toLocaleDateString() : "Never"} diff --git a/frontend/src/routes/network/+page.svelte b/frontend/src/routes/network/+page.svelte index 82110aa..6faf48f 100644 --- a/frontend/src/routes/network/+page.svelte +++ b/frontend/src/routes/network/+page.svelte @@ -1,6 +1,11 @@ @@ -55,18 +66,24 @@ Active Services - Overview of deployed applications and their internal/external ports. + Overview of deployed applications and their + internal/external ports. - {#if projects.length === 0} + {#if projects.length === 0 && databases.length === 0}
- -

No services found

+ +

+ No services found +

- Deploy a project to populate the network map. + Deploy a project or create a database to + populate the network map.

{:else} @@ -78,15 +95,21 @@ Port URL Status - Action + Action {#each projects as project} -
- +
+
- localhost + localhost {project.port} - + http://localhost:{project.port} @@ -122,6 +149,47 @@ {/each} + {#each databases as db} + + + + + localhost + {db.port} + + {db.type}://localhost:{db.port} + + + + {db.status} + + + + + + + {/each} {/if} diff --git a/frontend/src/routes/projects/[id]/deployments/+page.svelte b/frontend/src/routes/projects/[id]/deployments/+page.svelte index a57dc08..d2ed9e6 100644 --- a/frontend/src/routes/projects/[id]/deployments/+page.svelte +++ b/frontend/src/routes/projects/[id]/deployments/+page.svelte @@ -1,7 +1,7 @@ {#if loading} @@ -84,6 +102,9 @@ History of your application builds.

+ @@ -130,6 +151,7 @@ {deployment.commit === "HEAD" ? "HEAD" @@ -178,6 +200,17 @@ > + {#if deployment.commit !== "HEAD" && deployment.commit !== "MANUAL" && deployment.commit !== "WEBHOOK"} + + {/if} {/each} diff --git a/frontend/src/routes/projects/[id]/settings/+page.svelte b/frontend/src/routes/projects/[id]/settings/+page.svelte index 10fc764..2becb11 100644 --- a/frontend/src/routes/projects/[id]/settings/+page.svelte +++ b/frontend/src/routes/projects/[id]/settings/+page.svelte @@ -1,10 +1,22 @@ + +
+
+
+

+ Session & API Access +

+

+ Manage your API keys for CLI and external access. +

+
+ + {#if loading} +
+ +
+ {:else} + + + Personal Access Token + + Use this token to authenticate with the Clickploy CLI + and API. Treat this token like a password. + + + +
+ +
+ + +
+
+ +
+
+ + CLI Usage +
+
+ clickploy login --token {apiKey} +
+

+ Or use it in the Authorization header for API + requests: +

+
+ Authorization: Bearer {apiKey} +
+
+
+ + + Last generated: {new Date().toLocaleDateString()} + + + +
+ {/if} +
+
diff --git a/frontend/src/routes/storage/+page.svelte b/frontend/src/routes/storage/+page.svelte index bcf5ad6..ba98f82 100644 --- a/frontend/src/routes/storage/+page.svelte +++ b/frontend/src/routes/storage/+page.svelte @@ -5,7 +5,21 @@ Plus, Server, AlertCircle, + Trash, + Copy, + Activity, + Calendar, + Cloud, + Box, + Check, + Power, + Play, + RotateCw, } from "@lucide/svelte"; + import * as Tabs from "$lib/components/ui/tabs"; + import * as Dialog from "$lib/components/ui/dialog"; + import { Input } from "$lib/components/ui/input"; + import { Label } from "$lib/components/ui/label"; import { Button } from "$lib/components/ui/button"; import { Card, @@ -13,10 +27,23 @@ CardHeader, CardTitle, CardDescription, + CardFooter, } from "$lib/components/ui/card"; import { Progress } from "$lib/components/ui/progress"; + import { Badge } from "$lib/components/ui/badge"; - import { getStorageStats, listDatabases, createDatabase } from "$lib/api"; + import { + getStorageStats, + listDatabases, + createDatabase, + deleteDatabase, + getDatabaseCredentials, + updateDatabaseCredentials, + updateDatabase, + stopDatabase, + restartDatabase, + type Database as DatabaseType, + } from "$lib/api"; import { onMount } from "svelte"; import { toast } from "svelte-sonner"; @@ -26,27 +53,76 @@ totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0, ); - let userDatabases = $state([]); + let userDatabases = $state([]); let loading = $state(true); + // Credentials Dialog State (Creation) + let showCredsDialog = $state(false); + let newCreds = $state({ + uri: "", + username: "", + password: "", + port: 0, + name: "", + host: "localhost", + }); + + // Management Dialog State + let showManageDialog = $state(false); + let manageDb = $state(null); + let manageCreds = $state({ + username: "", + password: "", + uri: "", + public_uri: "", + loading: true, + port: 0, + }); + let isUpdatingCreds = $state(false); + let isPowerAction = $state(false); + + // Creation Dialog State + let showCreateDialog = $state(false); + let selectedDbType = $state(""); + let newDbName = $state(""); + let isCreating = $state(false); + const availableTypes = [ { name: "SQLite", description: "Embedded, serverless database engine.", type: "sqlite", status: "Available", + icon: Box, + color: "text-blue-500", + bgColor: "bg-blue-500/10", + }, + { + name: "MongoDB", + description: "NoSQL document database.", + type: "mongodb", + status: "Available", + icon: Database, + color: "text-green-500", + bgColor: "bg-green-500/10", }, { name: "PostgreSQL", description: "Advanced open source relational database.", type: "postgres", status: "Coming Soon", + icon: Cloud, + color: "text-indigo-500", + bgColor: "bg-indigo-500/10", }, { name: "Redis", description: "In-memory data structure store.", type: "redis", status: "Coming Soon", + icon: Activity, + color: "text-red-500", + bgColor: "bg-red-500/10", }, ]; @@ -65,129 +141,693 @@ loading = false; } - async function handleCreate(type: string) { - const name = prompt("Enter database name:"); - if (!name) return; + function initiateCreate(type: string) { + selectedDbType = type; + newDbName = ""; + showCreateDialog = true; + } - const res = await createDatabase(name, type); - if (res) { - toast.success("Database created successfully!"); + async function performCreate() { + if (!newDbName.trim()) return; + isCreating = true; + + try { + const res: any = await createDatabase(newDbName, selectedDbType); + showCreateDialog = false; // Close name input dialog + + if (res) { + toast.success("Database created successfully!"); + if (res.uri) { + newCreds = { + uri: res.uri, + username: res.username, + password: res.password, + port: res.database.port, + name: res.database.name, + host: window.location.hostname, + }; + // Handle localhost vs public IP (simplified logic) + if ( + newCreds.host !== "localhost" && + newCreds.host !== "127.0.0.1" + ) { + newCreds.uri = newCreds.uri.replace( + "@localhost", + `@${newCreds.host}`, + ); + } + showCredsDialog = true; + } + loadData(); + } + } catch (e) { + toast.error("Failed to create database"); + console.error(e); + } finally { + isCreating = false; + } + } + + function copyToClipboard(text: string) { + navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + } + + async function handleDelete(id: number) { + if ( + !confirm( + "Are you sure you want to delete this database? This action cannot be undone.", + ) + ) + return; + + const success = await deleteDatabase(id.toString()); + if (success) { + toast.success("Database deleted successfully!"); loadData(); } } + async function handleCardClick(db: DatabaseType) { + if (db.type !== "mongodb") return; // Only Mongo supported for now for rich management + manageDb = db; + showManageDialog = true; + manageCreds = { ...manageCreds, loading: true }; + + const creds = await getDatabaseCredentials(db.ID.toString()); + if (creds) { + manageCreds = { + username: creds.username, + password: creds.password, + uri: creds.uri, + public_uri: creds.public_uri, + loading: false, + port: db.port, + }; + } else { + manageCreds.loading = false; + toast.error("Failed to load credentials"); + } + } + + async function performUpdateCreds() { + if (!manageDb) return; + isUpdatingCreds = true; + + try { + // Update Port if changed + if (manageCreds.port !== manageDb.port) { + const res = await updateDatabase( + manageDb.ID.toString(), + Number(manageCreds.port), + ); + if (res) { + toast.success("Port updated successfully!"); + await loadData(); + manageDb = + userDatabases.find((d) => d.ID === manageDb!.ID) || + manageDb; + } + } + + // Update Credentials + const success = await updateDatabaseCredentials( + manageDb.ID.toString(), + manageCreds.username, + manageCreds.password, + ); + + if (success) { + toast.success("Credentials updated successfully!"); + const creds = await getDatabaseCredentials( + manageDb.ID.toString(), + ); + if (creds) { + manageCreds.uri = creds.uri; + } + } + } catch (e) { + console.error(e); + } finally { + isUpdatingCreds = false; + } + } + + async function performStopDatabase() { + if (!manageDb) return; + isPowerAction = true; + const res = await stopDatabase(manageDb.ID.toString()); + if (res) { + toast.success("Database stopped successfully"); + manageDb.status = "stopped"; + // Update the list + userDatabases = userDatabases.map((d) => + d.ID === manageDb?.ID ? { ...d, status: "stopped" } : d, + ); + } + isPowerAction = false; + } + + async function performRestartDatabase() { + if (!manageDb) return; + isPowerAction = true; + const res = await restartDatabase(manageDb.ID.toString()); + if (res) { + toast.success("Database restarted successfully"); + manageDb.status = "running"; + // Update the list + userDatabases = userDatabases.map((d) => + d.ID === manageDb?.ID ? { ...d, status: "running" } : d, + ); + } + isPowerAction = false; + } + onMount(loadData); -
-
+
+
-

Storage

-

- Manage databases and view storage usage. +

Databases

+

+ Manage your database instances and storage volume.

-
-
- - - - Storage Usage +
+ + + + Storage Volume - Total disk space used on the host machine. + Total disk space usage across all your deployments and + databases.
-
- {(usedStorage / 1024).toFixed(2)} GB used - {(totalStorage / 1024).toFixed(2)} GB total +
+ {(usedStorage / 1024).toFixed(2)} GB + used of {(totalStorage / 1024).toFixed(2)} GB +
+ {usagePercent.toFixed(1)}%
- -

- You are using {usagePercent.toFixed(1)}% of available storage. -

+
-

Your Databases

- {#if userDatabases.length === 0} -
- No databases created yet. -
- {:else} -
- {#each userDatabases as db} -
-
-
- -
-
-

{db.name}

-

- {db.type} • {new Date(db.CreatedAt).toLocaleDateString()} -

-
-
- - {db.status} - -
- {/each} -
- {/if} - -

Create New

-
- {#each availableTypes as db} -
+
+

-
-
- -
-
-

{db.name}

-

{db.description}

-
+ + Active Databases +

+ + {#if loading} +
+ {#each Array(3) as _} +
+ {/each}
-
- +
- {db.status} - + +
+

No databases yet

+

+ Create your first database to get started. +

+
+ {:else} +
+ {#each userDatabases as db} + +
+ + {/each} +
+ {/if} +
+ +
+

+ + Create New Database +

+
+ {#each availableTypes as db} + + {/each} +
+
+
+
+ + + + + + Create {availableTypes.find((t) => t.type === selectedDbType) + ?.name} Database + + Enter a unique name for your new database instance. + + +
+
+ + +
+
+ + + + +
+
+ + + + + + + Database Created + + + Your database is ready. These variables are only shown once, so + please copy them now. + + +
+
+ +
+
- {/each} -
-
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ +
+

+ Important +

+

+ This database is accessible via port {newCreds.port}. Make sure to allow this port in your firewall if + accessing externally. +

+
+
+
+
+ + + + + + + + + + + + Manage {manageDb?.name} + + + Configure connection details, credentials, and instance power + state. + + + + {#if manageCreds.loading} +
+
+
+ {:else} + + + Connection + Settings & Danger + + + +
+
+
+

Status

+

+ Current state of the database container. +

+
+ + {manageDb?.status} + +
+ +
+ +
+ + +
+
+ +
+
+ +
+ localhost +
+
+
+ + +
+
+
+ +
+ +
+
+ + +
+

Credentials

+
+
+ + +
+
+ +
+ + +
+
+ +
+
+ +
+
+

+ Power Actions +

+

+ Manage the running state of your database. +

+
+ +
+ + +
+
+
+
+ {/if} +
+