API and Database Deployment Update
This commit is contained in:
@@ -1,32 +1,25 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"clickploy/internal/api"
|
"clickploy/internal/api"
|
||||||
"clickploy/internal/builder"
|
"clickploy/internal/builder"
|
||||||
"clickploy/internal/db"
|
"clickploy/internal/db"
|
||||||
"clickploy/internal/deployer"
|
"clickploy/internal/deployer"
|
||||||
"clickploy/internal/ports"
|
"clickploy/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
db.Init(".")
|
db.Init(".")
|
||||||
pm := ports.NewManager(4000, 5000)
|
pm := ports.NewManager(2000, 60000)
|
||||||
buildr := builder.NewBuilder()
|
buildr := builder.NewBuilder()
|
||||||
dply, err := deployer.NewDeployer()
|
dply, err := deployer.NewDeployer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create deployer: %v", err)
|
log.Fatalf("Failed to create deployer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := api.NewHandler(buildr, dply, pm)
|
handler := api.NewHandler(buildr, dply, pm)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
r.Use(cors.New(cors.Config{
|
r.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: []string{"*"},
|
AllowOrigins: []string{"*"},
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
@@ -35,7 +28,6 @@ func main() {
|
|||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
MaxAge: 12 * time.Hour,
|
MaxAge: 12 * time.Hour,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
handler.RegisterRoutes(r)
|
handler.RegisterRoutes(r)
|
||||||
handler.RegisterAuthRoutes(r)
|
handler.RegisterAuthRoutes(r)
|
||||||
handler.RegisterUserRoutes(r)
|
handler.RegisterUserRoutes(r)
|
||||||
@@ -44,7 +36,6 @@ func main() {
|
|||||||
handler.RegisterSystemRoutes(r)
|
handler.RegisterSystemRoutes(r)
|
||||||
handler.RegisterStorageRoutes(r)
|
handler.RegisterStorageRoutes(r)
|
||||||
handler.RegisterAdminRoutes(r)
|
handler.RegisterAdminRoutes(r)
|
||||||
|
|
||||||
log.Println("Starting Clickploy Backend on :8080")
|
log.Println("Starting Clickploy Backend on :8080")
|
||||||
if err := r.Run(":8080"); err != nil {
|
if err := r.Run(":8080"); err != nil {
|
||||||
log.Fatalf("Server failed: %v", err)
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"clickploy/internal/db"
|
"clickploy/internal/db"
|
||||||
"clickploy/internal/models"
|
"clickploy/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) RegisterAdminRoutes(r *gin.Engine) {
|
func (h *Handler) RegisterAdminRoutes(r *gin.Engine) {
|
||||||
admin := r.Group("/api/admin", AuthMiddleware(), AdminMiddleware())
|
admin := r.Group("/api/admin", AuthMiddleware(), AdminMiddleware())
|
||||||
{
|
{
|
||||||
@@ -17,7 +13,6 @@ func (h *Handler) RegisterAdminRoutes(r *gin.Engine) {
|
|||||||
admin.GET("/stats", h.adminGetStats)
|
admin.GET("/stats", h.adminGetStats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) adminListUsers(c *gin.Context) {
|
func (h *Handler) adminListUsers(c *gin.Context) {
|
||||||
var users []models.User
|
var users []models.User
|
||||||
if err := db.DB.Preload("Projects").Find(&users).Error; err != nil {
|
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)
|
c.JSON(http.StatusOK, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) adminDeleteUser(c *gin.Context) {
|
func (h *Handler) adminDeleteUser(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
if err := db.DB.Where("id = ?", id).Delete(&models.User{}).Error; err != nil {
|
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"})
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) adminGetStats(c *gin.Context) {
|
func (h *Handler) adminGetStats(c *gin.Context) {
|
||||||
var userCount int64
|
var userCount int64
|
||||||
var projectCount int64
|
var projectCount int64
|
||||||
var deploymentCount int64
|
var deploymentCount int64
|
||||||
|
|
||||||
db.DB.Model(&models.User{}).Count(&userCount)
|
db.DB.Model(&models.User{}).Count(&userCount)
|
||||||
db.DB.Model(&models.Project{}).Count(&projectCount)
|
db.DB.Model(&models.Project{}).Count(&projectCount)
|
||||||
db.DB.Model(&models.Deployment{}).Count(&deploymentCount)
|
db.DB.Model(&models.Deployment{}).Count(&deploymentCount)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"users": userCount,
|
"users": userCount,
|
||||||
"projects": projectCount,
|
"projects": projectCount,
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"clickploy/internal/builder"
|
||||||
|
"clickploy/internal/deployer"
|
||||||
|
"clickploy/internal/ports"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"clickploy/internal/builder"
|
|
||||||
"clickploy/internal/deployer"
|
|
||||||
"clickploy/internal/ports"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -32,7 +33,6 @@ type DeployRequest struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
GitToken string `json:"git_token"`
|
GitToken string `json:"git_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeployResponse struct {
|
type DeployResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
AppName string `json:"app_name"`
|
AppName string `json:"app_name"`
|
||||||
@@ -45,32 +45,27 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
|||||||
r.POST("/deploy", h.handleDeploy)
|
r.POST("/deploy", h.handleDeploy)
|
||||||
h.RegisterStreamRoutes(r)
|
h.RegisterStreamRoutes(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleDeploy(c *gin.Context) {
|
func (h *Handler) handleDeploy(c *gin.Context) {
|
||||||
var req DeployRequest
|
var req DeployRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Build failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Build failed: %v", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
port, err := h.ports.GetPort(req.Name, req.Port)
|
port, err := h.ports.GetPort(req.Name, req.Port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, nil)
|
_, err = h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Deployment failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Deployment failed: %v", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, DeployResponse{
|
c.JSON(http.StatusOK, DeployResponse{
|
||||||
Status: "success",
|
Status: "success",
|
||||||
AppName: req.Name,
|
AppName: req.Name,
|
||||||
@@ -79,14 +74,42 @@ func (h *Handler) handleDeploy(c *gin.Context) {
|
|||||||
Message: "Container started successfully",
|
Message: "Container started successfully",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterSystemRoutes(r *gin.Engine) {
|
func (h *Handler) RegisterSystemRoutes(r *gin.Engine) {
|
||||||
r.GET("/api/system/status", h.handleSystemStatus)
|
r.GET("/api/system/status", h.handleSystemStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleSystemStatus(c *gin.Context) {
|
func (h *Handler) handleSystemStatus(c *gin.Context) {
|
||||||
|
localIP := GetLocalIP()
|
||||||
|
publicIP := GetPublicIP()
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"version": "v0.1.0",
|
"version": "v0.1.0",
|
||||||
"status": "All systems normal",
|
"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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"clickploy/internal/auth"
|
"clickploy/internal/auth"
|
||||||
"clickploy/internal/db"
|
"clickploy/internal/db"
|
||||||
"clickploy/internal/models"
|
"clickploy/internal/models"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
@@ -16,7 +17,6 @@ type AuthRequest struct {
|
|||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
User models.User `json:"user"`
|
User models.User `json:"user"`
|
||||||
@@ -29,126 +29,131 @@ func (h *Handler) RegisterAuthRoutes(r *gin.Engine) {
|
|||||||
authGroup.POST("/login", h.login)
|
authGroup.POST("/login", h.login)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) register(c *gin.Context) {
|
func (h *Handler) register(c *gin.Context) {
|
||||||
var req AuthRequest
|
var req AuthRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hashed, err := auth.HashPassword(req.Password)
|
hashed, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
db.DB.Model(&models.User{}).Count(&count)
|
db.DB.Model(&models.User{}).Count(&count)
|
||||||
|
apiKeyBytes := make([]byte, 32)
|
||||||
|
rand.Read(apiKeyBytes)
|
||||||
|
apiKey := "cp_" + hex.EncodeToString(apiKeyBytes)
|
||||||
userID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
userID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
user := models.User{
|
user := models.User{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
Password: hashed,
|
Password: hashed,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Avatar: "https://github.com/shadcn.png",
|
Avatar: "https://ui-avatars.com/api/?name=" + req.Name,
|
||||||
IsAdmin: count == 0,
|
IsAdmin: count == 0,
|
||||||
|
APIKey: apiKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if result := db.DB.Create(&user); result.Error != nil {
|
if result := db.DB.Create(&user); result.Error != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := auth.GenerateToken(user.ID, user.Email)
|
token, _ := auth.GenerateToken(user.ID, user.Email)
|
||||||
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
|
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) login(c *gin.Context) {
|
func (h *Handler) login(c *gin.Context) {
|
||||||
var req AuthRequest
|
var req AuthRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
var user models.User
|
||||||
if result := db.DB.Where("email = ?", req.Email).First(&user); result.Error != nil {
|
if result := db.DB.Where("email = ?", req.Email).First(&user); result.Error != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auth.CheckPassword(req.Password, user.Password) {
|
if !auth.CheckPassword(req.Password, user.Password) {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := auth.GenerateToken(user.ID, user.Email)
|
token, _ := auth.GenerateToken(user.ID, user.Email)
|
||||||
c.JSON(http.StatusOK, AuthResponse{Token: token, User: user})
|
c.JSON(http.StatusOK, AuthResponse{Token: token, User: user})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterUserRoutes(r *gin.Engine) {
|
func (h *Handler) RegisterUserRoutes(r *gin.Engine) {
|
||||||
userGroup := r.Group("/api/user", AuthMiddleware())
|
userGroup := r.Group("/api/user", AuthMiddleware())
|
||||||
{
|
{
|
||||||
|
userGroup.GET("/", h.getMe)
|
||||||
userGroup.PUT("/profile", h.updateProfile)
|
userGroup.PUT("/profile", h.updateProfile)
|
||||||
userGroup.PUT("/password", h.updatePassword)
|
userGroup.PUT("/password", h.updatePassword)
|
||||||
|
userGroup.POST("/key", h.regenerateAPIKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) updateProfile(c *gin.Context) {
|
func (h *Handler) updateProfile(c *gin.Context) {
|
||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
var user models.User
|
||||||
if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil {
|
if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name != "" {
|
if req.Name != "" {
|
||||||
user.Name = req.Name
|
user.Name = req.Name
|
||||||
}
|
}
|
||||||
if req.Email != "" {
|
if req.Email != "" {
|
||||||
user.Email = req.Email
|
user.Email = req.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
db.DB.Save(&user)
|
db.DB.Save(&user)
|
||||||
c.JSON(http.StatusOK, user)
|
c.JSON(http.StatusOK, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) updatePassword(c *gin.Context) {
|
func (h *Handler) updatePassword(c *gin.Context) {
|
||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
var req struct {
|
var req struct {
|
||||||
OldPassword string `json:"old_password" binding:"required"`
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
var user models.User
|
||||||
if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil {
|
if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auth.CheckPassword(req.OldPassword, user.Password) {
|
if !auth.CheckPassword(req.OldPassword, user.Password) {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Incorrect old password"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Incorrect old password"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hashed, _ := auth.HashPassword(req.NewPassword)
|
hashed, _ := auth.HashPassword(req.NewPassword)
|
||||||
user.Password = hashed
|
user.Password = hashed
|
||||||
db.DB.Save(&user)
|
db.DB.Save(&user)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"clickploy/internal/auth"
|
"clickploy/internal/auth"
|
||||||
"clickploy/internal/db"
|
"clickploy/internal/db"
|
||||||
"clickploy/internal/models"
|
"clickploy/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthMiddleware() gin.HandlerFunc {
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
@@ -19,21 +15,25 @@ func AuthMiddleware() gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
claims, err := auth.ValidateToken(tokenString)
|
claims, err := auth.ValidateToken(tokenString)
|
||||||
if err != nil {
|
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.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("userID", claims.UserID)
|
c.Set("userID", claims.UserID)
|
||||||
c.Set("email", claims.Email)
|
c.Set("email", claims.Email)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminMiddleware() gin.HandlerFunc {
|
func AdminMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
userID, exists := c.Get("userID")
|
userID, exists := c.Get("userID")
|
||||||
@@ -42,20 +42,17 @@ func AdminMiddleware() gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
var user models.User
|
||||||
if err := db.DB.Where("id = ?", userID).First(&user).Error; err != nil {
|
if err := db.DB.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !user.IsAdmin {
|
if !user.IsAdmin {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin privileges required"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin privileges required"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"clickploy/internal/db"
|
||||||
|
"clickploy/internal/models"
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"clickploy/internal/db"
|
|
||||||
"clickploy/internal/models"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
@@ -22,16 +23,51 @@ func (h *Handler) RegisterProjectRoutes(r *gin.Engine) {
|
|||||||
protected.POST("/projects", h.createProject)
|
protected.POST("/projects", h.createProject)
|
||||||
protected.GET("/projects", h.listProjects)
|
protected.GET("/projects", h.listProjects)
|
||||||
protected.GET("/projects/:id", h.getProject)
|
protected.GET("/projects/:id", h.getProject)
|
||||||
|
protected.PUT("/projects/:id", h.updateProject)
|
||||||
protected.PUT("/projects/:id/env", h.updateProjectEnv)
|
protected.PUT("/projects/:id/env", h.updateProjectEnv)
|
||||||
protected.POST("/projects/:id/redeploy", h.redeployProject)
|
protected.POST("/projects/:id/redeploy", h.redeployProject)
|
||||||
protected.GET("/activity", h.getActivity)
|
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) {
|
func (h *Handler) updateProjectEnv(c *gin.Context) {
|
||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
projectID := c.Param("id")
|
projectID := c.Param("id")
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
EnvVars map[string]string `json:"env_vars"`
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var project models.Project
|
var project models.Project
|
||||||
if result := db.DB.Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil {
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tx := db.DB.Begin()
|
tx := db.DB.Begin()
|
||||||
if err := tx.Where("project_id = ?", project.ID).Delete(&models.EnvVar{}).Error; err != nil {
|
if err := tx.Where("project_id = ?", project.ID).Delete(&models.EnvVar{}).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update env vars"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update env vars"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range req.EnvVars {
|
for k, v := range req.EnvVars {
|
||||||
envVar := models.EnvVar{
|
envVar := models.EnvVar{
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
@@ -65,99 +98,93 @@ func (h *Handler) updateProjectEnv(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit()
|
tx.Commit()
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) redeployProject(c *gin.Context) {
|
func (h *Handler) redeployProject(c *gin.Context) {
|
||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
projectID := c.Param("id")
|
projectID := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
var project models.Project
|
var project models.Project
|
||||||
if result := db.DB.Preload("EnvVars").Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil {
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
|
initialCommit := "MANUAL"
|
||||||
|
if req.Commit != "" {
|
||||||
|
initialCommit = req.Commit
|
||||||
|
}
|
||||||
deployment := models.Deployment{
|
deployment := models.Deployment{
|
||||||
ID: depID,
|
ID: depID,
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
Status: "building",
|
Status: "building",
|
||||||
Commit: "MANUAL",
|
Commit: initialCommit,
|
||||||
Logs: "Starting manual redeploy...",
|
Logs: fmt.Sprintf("Starting redeploy for commit %s...", initialCommit),
|
||||||
}
|
}
|
||||||
db.DB.Create(&deployment)
|
db.DB.Create(&deployment)
|
||||||
|
|
||||||
go func() {
|
|
||||||
var logBuffer bytes.Buffer
|
|
||||||
streamer := &StreamWriter{DeploymentID: deployment.ID}
|
|
||||||
multi := io.MultiWriter(&logBuffer, streamer)
|
|
||||||
|
|
||||||
envMap := make(map[string]string)
|
envMap := make(map[string]string)
|
||||||
for _, env := range project.EnvVars {
|
for _, env := range project.EnvVars {
|
||||||
envMap[env.Key] = env.Value
|
envMap[env.Key] = env.Value
|
||||||
}
|
}
|
||||||
|
resolvedEnv, err := h.resolveEnvVars(c.Request.Context(), userID.(string), envMap)
|
||||||
imageName, commitHash, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi)
|
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)
|
||||||
|
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()
|
deployment.Logs = logBuffer.String()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deployment.Status = "failed"
|
deployment.Status = "failed"
|
||||||
deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err)
|
deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err)
|
||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update commit hash if we got one
|
|
||||||
if commitHash != "" {
|
if commitHash != "" {
|
||||||
deployment.Commit = commitHash
|
deployment.Commit = commitHash
|
||||||
}
|
}
|
||||||
|
|
||||||
var envStrings []string
|
var envStrings []string
|
||||||
for _, env := range project.EnvVars {
|
for k, v := range resolvedEnv {
|
||||||
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
|
envStrings = append(envStrings, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
containerID, err := h.deployer.RunContainer(context.Background(), imageName, project.Name, project.Port, envStrings)
|
||||||
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, project.Name, project.Port, envStrings)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deployment.Status = "failed"
|
deployment.Status = "failed"
|
||||||
deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err)
|
deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err)
|
||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deployment.Status = "live"
|
deployment.Status = "live"
|
||||||
deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port)
|
deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port)
|
||||||
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
|
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
|
||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID})
|
c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getActivity(c *gin.Context) {
|
func (h *Handler) getActivity(c *gin.Context) {
|
||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
var deployments []models.Deployment
|
var deployments []models.Deployment
|
||||||
|
|
||||||
err := db.DB.Joins("JOIN projects ON projects.id = deployments.project_id").
|
err := db.DB.Joins("JOIN projects ON projects.id = deployments.project_id").
|
||||||
Where("projects.owner_id = ?", userID).
|
Where("projects.owner_id = ?", userID).
|
||||||
Order("deployments.created_at desc").
|
Order("deployments.created_at desc").
|
||||||
Limit(20).
|
Limit(20).
|
||||||
Preload("Project").
|
Preload("Project").
|
||||||
Find(&deployments).Error
|
Find(&deployments).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch activity"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch activity"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, deployments)
|
c.JSON(http.StatusOK, deployments)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) createProject(c *gin.Context) {
|
func (h *Handler) createProject(c *gin.Context) {
|
||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
DeployRequest
|
DeployRequest
|
||||||
EnvVars map[string]string `json:"env_vars"`
|
EnvVars map[string]string `json:"env_vars"`
|
||||||
@@ -166,31 +193,28 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
InstallCommand string `json:"install_command"`
|
InstallCommand string `json:"install_command"`
|
||||||
Runtime string `json:"runtime"`
|
Runtime string `json:"runtime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
port, err := h.ports.GetPort(req.Name, req.Port)
|
port, err := h.ports.GetPort(req.Name, req.Port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)})
|
||||||
return
|
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 envVarsModel []models.EnvVar
|
||||||
var envStrings []string
|
|
||||||
for k, v := range req.EnvVars {
|
for k, v := range req.EnvVars {
|
||||||
envStrings = append(envStrings, fmt.Sprintf("%s=%s", k, v))
|
|
||||||
envVarsModel = append(envVarsModel, models.EnvVar{Key: k, Value: v})
|
envVarsModel = append(envVarsModel, models.EnvVar{Key: k, Value: v})
|
||||||
}
|
}
|
||||||
|
|
||||||
secretBytes := make([]byte, 16)
|
secretBytes := make([]byte, 16)
|
||||||
rand.Read(secretBytes)
|
rand.Read(secretBytes)
|
||||||
webhookSecret := hex.EncodeToString(secretBytes)
|
webhookSecret := hex.EncodeToString(secretBytes)
|
||||||
|
|
||||||
id, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
id, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
|
|
||||||
project := models.Project{
|
project := models.Project{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -205,12 +229,10 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
InstallCommand: req.InstallCommand,
|
InstallCommand: req.InstallCommand,
|
||||||
Runtime: req.Runtime,
|
Runtime: req.Runtime,
|
||||||
}
|
}
|
||||||
|
|
||||||
if result := db.DB.Create(&project); result.Error != nil {
|
if result := db.DB.Create(&project); result.Error != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save project to DB"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save project to DB"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
deployment := models.Deployment{
|
deployment := models.Deployment{
|
||||||
ID: depID,
|
ID: depID,
|
||||||
@@ -220,45 +242,71 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
Logs: "Starting build...",
|
Logs: "Starting build...",
|
||||||
}
|
}
|
||||||
db.DB.Create(&deployment)
|
db.DB.Create(&deployment)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
var logBuffer bytes.Buffer
|
var logBuffer bytes.Buffer
|
||||||
streamer := &StreamWriter{DeploymentID: deployment.ID}
|
streamer := &StreamWriter{DeploymentID: deployment.ID}
|
||||||
multi := io.MultiWriter(&logBuffer, streamer)
|
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, resolvedEnv, multi)
|
||||||
imageName, commitHash, err := h.builder.Build(req.Repo, req.Name, req.GitToken, req.BuildCommand, req.StartCommand, req.InstallCommand, req.Runtime, req.EnvVars, multi)
|
|
||||||
deployment.Logs = logBuffer.String()
|
deployment.Logs = logBuffer.String()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deployment.Status = "failed"
|
deployment.Status = "failed"
|
||||||
deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err)
|
deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err)
|
||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update commit hash
|
|
||||||
if commitHash != "" {
|
if commitHash != "" {
|
||||||
deployment.Commit = commitHash
|
deployment.Commit = commitHash
|
||||||
}
|
}
|
||||||
|
var envStrings []string
|
||||||
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, envStrings)
|
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 {
|
if err != nil {
|
||||||
deployment.Status = "failed"
|
deployment.Status = "failed"
|
||||||
deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err)
|
deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err)
|
||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deployment.Status = "live"
|
deployment.Status = "live"
|
||||||
deployment.URL = fmt.Sprintf("http://localhost:%d", port)
|
deployment.URL = fmt.Sprintf("http://localhost:%d", port)
|
||||||
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
|
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
|
||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
project.Deployments = []models.Deployment{deployment}
|
project.Deployments = []models.Deployment{deployment}
|
||||||
c.JSON(http.StatusOK, project)
|
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) {
|
func (h *Handler) listProjects(c *gin.Context) {
|
||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
var projects []models.Project
|
var projects []models.Project
|
||||||
@@ -268,11 +316,9 @@ func (h *Handler) listProjects(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, projects)
|
c.JSON(http.StatusOK, projects)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getProject(c *gin.Context) {
|
func (h *Handler) getProject(c *gin.Context) {
|
||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
projectID := c.Param("id")
|
projectID := c.Param("id")
|
||||||
|
|
||||||
var project models.Project
|
var project models.Project
|
||||||
if result := db.DB.Order("created_at desc").Preload("Deployments", func(db *gorm.DB) *gorm.DB {
|
if result := db.DB.Order("created_at desc").Preload("Deployments", func(db *gorm.DB) *gorm.DB {
|
||||||
return db.Order("deployments.created_at desc")
|
return db.Order("deployments.created_at desc")
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"clickploy/internal/db"
|
||||||
|
"clickploy/internal/models"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"clickploy/internal/db"
|
|
||||||
"clickploy/internal/models"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateDatabaseRequest struct {
|
type CreateDatabaseRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
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 {
|
type StorageStatsResponse struct {
|
||||||
TotalMB float64 `json:"total_mb"`
|
TotalMB float64 `json:"total_mb"`
|
||||||
UsedMB float64 `json:"used_mb"`
|
UsedMB float64 `json:"used_mb"`
|
||||||
@@ -30,28 +30,67 @@ func (h *Handler) RegisterStorageRoutes(r *gin.Engine) {
|
|||||||
api.GET("/stats", h.handleGetStorageStats)
|
api.GET("/stats", h.handleGetStorageStats)
|
||||||
api.GET("/databases", h.handleListDatabases)
|
api.GET("/databases", h.handleListDatabases)
|
||||||
api.POST("/databases", h.handleCreateDatabase)
|
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) {
|
func (h *Handler) handleGetStorageStats(c *gin.Context) {
|
||||||
var stat syscall.Statfs_t
|
var stat syscall.Statfs_t
|
||||||
wd, _ := os.Getwd()
|
wd, _ := os.Getwd()
|
||||||
|
|
||||||
if err := syscall.Statfs(wd, &stat); err != nil {
|
if err := syscall.Statfs(wd, &stat); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get disk stats"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get disk stats"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
totalBytes := stat.Blocks * uint64(stat.Bsize)
|
totalBytes := stat.Blocks * uint64(stat.Bsize)
|
||||||
availBytes := stat.Bavail * uint64(stat.Bsize)
|
availBytes := stat.Bavail * uint64(stat.Bsize)
|
||||||
usedBytes := totalBytes - availBytes
|
usedBytes := totalBytes - availBytes
|
||||||
|
|
||||||
c.JSON(http.StatusOK, StorageStatsResponse{
|
c.JSON(http.StatusOK, StorageStatsResponse{
|
||||||
TotalMB: float64(totalBytes) / 1024 / 1024,
|
TotalMB: float64(totalBytes) / 1024 / 1024,
|
||||||
UsedMB: float64(usedBytes) / 1024 / 1024,
|
UsedMB: float64(usedBytes) / 1024 / 1024,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleListDatabases(c *gin.Context) {
|
func (h *Handler) handleListDatabases(c *gin.Context) {
|
||||||
userId := c.GetString("userID")
|
userId := c.GetString("userID")
|
||||||
var dbs []models.Database
|
var dbs []models.Database
|
||||||
@@ -61,7 +100,6 @@ func (h *Handler) handleListDatabases(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, dbs)
|
c.JSON(http.StatusOK, dbs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
||||||
userId := c.GetString("userID")
|
userId := c.GetString("userID")
|
||||||
var req CreateDatabaseRequest
|
var req CreateDatabaseRequest
|
||||||
@@ -69,7 +107,6 @@ func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newDB := models.Database{
|
newDB := models.Database{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
@@ -77,10 +114,9 @@ func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
|||||||
OwnerID: userId,
|
OwnerID: userId,
|
||||||
SizeMB: 0,
|
SizeMB: 0,
|
||||||
}
|
}
|
||||||
|
if req.Type == "sqlite" {
|
||||||
dataDir := "./data/user_dbs"
|
dataDir := "./data/user_dbs"
|
||||||
os.MkdirAll(dataDir, 0755)
|
os.MkdirAll(dataDir, 0755)
|
||||||
|
|
||||||
dbPath := filepath.Join(dataDir, fmt.Sprintf("%s_%s.db", userId, req.Name))
|
dbPath := filepath.Join(dataDir, fmt.Sprintf("%s_%s.db", userId, req.Name))
|
||||||
file, err := os.Create(dbPath)
|
file, err := os.Create(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,11 +124,247 @@ func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
file.Close()
|
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
|
||||||
|
}
|
||||||
if err := db.DB.Create(&newDB).Error; err != nil {
|
if err := db.DB.Create(&newDB).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save database record"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save database record"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, newDB)
|
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@<HOST>:%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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogHub struct {
|
type LogHub struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
streams map[string][]chan []byte
|
streams map[string][]chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
var Hub = &LogHub{
|
var Hub = &LogHub{
|
||||||
streams: make(map[string][]chan []byte),
|
streams: make(map[string][]chan []byte),
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *LogHub) Broadcast(deploymentID string, p []byte) {
|
func (h *LogHub) Broadcast(deploymentID string, p []byte) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
@@ -34,7 +28,6 @@ func (h *LogHub) Broadcast(deploymentID string, p []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *LogHub) Subscribe(deploymentID string) chan []byte {
|
func (h *LogHub) Subscribe(deploymentID string) chan []byte {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
@@ -42,7 +35,6 @@ func (h *LogHub) Subscribe(deploymentID string) chan []byte {
|
|||||||
h.streams[deploymentID] = append(h.streams[deploymentID], ch)
|
h.streams[deploymentID] = append(h.streams[deploymentID], ch)
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *LogHub) Unsubscribe(deploymentID string, ch chan []byte) {
|
func (h *LogHub) Unsubscribe(deploymentID string, ch chan []byte) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
@@ -59,34 +51,28 @@ func (h *LogHub) Unsubscribe(deploymentID string, ch chan []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamWriter struct {
|
type StreamWriter struct {
|
||||||
DeploymentID string
|
DeploymentID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *StreamWriter) Write(p []byte) (n int, err error) {
|
func (w *StreamWriter) Write(p []byte) (n int, err error) {
|
||||||
c := make([]byte, len(p))
|
c := make([]byte, len(p))
|
||||||
copy(c, p)
|
copy(c, p)
|
||||||
Hub.Broadcast(w.DeploymentID, c)
|
Hub.Broadcast(w.DeploymentID, c)
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) streamDeploymentLogs(c *gin.Context) {
|
func (h *Handler) streamDeploymentLogs(c *gin.Context) {
|
||||||
deploymentID := c.Param("id")
|
deploymentID := c.Param("id")
|
||||||
if deploymentID == "" {
|
if deploymentID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
logChan := Hub.Subscribe(deploymentID)
|
logChan := Hub.Subscribe(deploymentID)
|
||||||
defer Hub.Unsubscribe(deploymentID, logChan)
|
defer Hub.Unsubscribe(deploymentID, logChan)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
if _, _, err := conn.NextReader(); err != nil {
|
if _, _, err := conn.NextReader(); err != nil {
|
||||||
@@ -95,7 +81,6 @@ func (h *Handler) streamDeploymentLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for logChunk := range logChan {
|
for logChunk := range logChan {
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, logChunk); err != nil {
|
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) {
|
func (h *Handler) RegisterStreamRoutes(r *gin.Engine) {
|
||||||
r.GET("/api/deployments/:id/logs/stream", h.streamDeploymentLogs)
|
r.GET("/api/deployments/:id/logs/stream", h.streamDeploymentLogs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"clickploy/internal/db"
|
||||||
|
"clickploy/internal/models"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"clickploy/internal/db"
|
|
||||||
"clickploy/internal/models"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
)
|
)
|
||||||
@@ -16,27 +15,22 @@ import (
|
|||||||
func (h *Handler) RegisterWebhookRoutes(r *gin.Engine) {
|
func (h *Handler) RegisterWebhookRoutes(r *gin.Engine) {
|
||||||
r.POST("/projects/:projectID/webhook/:webhookID", h.handleWebhook)
|
r.POST("/projects/:projectID/webhook/:webhookID", h.handleWebhook)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleWebhook(c *gin.Context) {
|
func (h *Handler) handleWebhook(c *gin.Context) {
|
||||||
projectID := c.Param("projectID")
|
projectID := c.Param("projectID")
|
||||||
webhookSecret := c.Param("webhookID")
|
webhookSecret := c.Param("webhookID")
|
||||||
|
|
||||||
if projectID == "" || webhookSecret == "" {
|
if projectID == "" || webhookSecret == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook url"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook url"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var project models.Project
|
var project models.Project
|
||||||
if result := db.DB.Preload("EnvVars").Where("id = ?", projectID).First(&project); result.Error != nil {
|
if result := db.DB.Preload("EnvVars").Where("id = ?", projectID).First(&project); result.Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.WebhookSecret != webhookSecret {
|
if project.WebhookSecret != webhookSecret {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Webhook Secret"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Webhook Secret"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
deployment := models.Deployment{
|
deployment := models.Deployment{
|
||||||
ID: depID,
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment record"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
var logBuffer bytes.Buffer
|
var logBuffer bytes.Buffer
|
||||||
streamer := &StreamWriter{DeploymentID: deployment.ID}
|
streamer := &StreamWriter{DeploymentID: deployment.ID}
|
||||||
multi := io.MultiWriter(&logBuffer, streamer)
|
multi := io.MultiWriter(&logBuffer, streamer)
|
||||||
|
|
||||||
envMap := make(map[string]string)
|
envMap := make(map[string]string)
|
||||||
for _, env := range project.EnvVars {
|
for _, env := range project.EnvVars {
|
||||||
envMap[env.Key] = env.Value
|
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()
|
deployment.Logs = logBuffer.String()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deployment.Status = "failed"
|
deployment.Status = "failed"
|
||||||
@@ -68,16 +59,13 @@ func (h *Handler) handleWebhook(c *gin.Context) {
|
|||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if commitHash != "" {
|
if commitHash != "" {
|
||||||
deployment.Commit = commitHash
|
deployment.Commit = commitHash
|
||||||
}
|
}
|
||||||
|
|
||||||
var envStrings []string
|
var envStrings []string
|
||||||
for _, env := range project.EnvVars {
|
for _, env := range project.EnvVars {
|
||||||
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
|
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)
|
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, project.Name, project.Port, envStrings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deployment.Status = "failed"
|
deployment.Status = "failed"
|
||||||
@@ -85,12 +73,10 @@ func (h *Handler) handleWebhook(c *gin.Context) {
|
|||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deployment.Status = "live"
|
deployment.Status = "live"
|
||||||
deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port)
|
deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port)
|
||||||
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
|
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
|
||||||
db.DB.Save(&deployment)
|
db.DB.Save(&deployment)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID})
|
c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var SecretKey = []byte("super-secret-key-change-me")
|
var SecretKey = []byte("super-secret-key-change-me")
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||||
return string(bytes), err
|
return string(bytes), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckPassword(password, hash string) bool {
|
func CheckPassword(password, hash string) bool {
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateToken(userID string, email string) (string, error) {
|
func GenerateToken(userID string, email string) (string, error) {
|
||||||
expirationTime := time.Now().Add(24 * time.Hour)
|
expirationTime := time.Now().Add(24 * time.Hour)
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
@@ -38,7 +31,6 @@ func GenerateToken(userID string, email string) (string, error) {
|
|||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return token.SignedString(SecretKey)
|
return token.SignedString(SecretKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateToken(tokenString string) (*Claims, error) {
|
func ValidateToken(tokenString string) (*Claims, error) {
|
||||||
claims := &Claims{}
|
claims := &Claims{}
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
package builder
|
package builder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -9,14 +8,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Builder struct{}
|
type Builder struct{}
|
||||||
|
|
||||||
func NewBuilder() *Builder {
|
func NewBuilder() *Builder {
|
||||||
return &Builder{}
|
return &Builder{}
|
||||||
}
|
}
|
||||||
|
func (b *Builder) Build(repoURL, targetCommit, appName, gitToken, buildCmd, startCmd, installCmd, runtime string, envVars map[string]string, logWriter io.Writer) (string, string, error) {
|
||||||
func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installCmd, runtime string, envVars map[string]string, logWriter io.Writer) (string, string, error) {
|
|
||||||
workDir := filepath.Join("/tmp", "paas-builds", appName)
|
workDir := filepath.Join("/tmp", "paas-builds", appName)
|
||||||
if err := os.RemoveAll(workDir); err != nil {
|
if err := os.RemoveAll(workDir); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to clean work dir: %w", err)
|
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 {
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to create work dir: %w", err)
|
return "", "", fmt.Errorf("failed to create work dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cloneURL := repoURL
|
cloneURL := repoURL
|
||||||
if gitToken != "" {
|
if gitToken != "" {
|
||||||
u, err := url.Parse(repoURL)
|
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)
|
u.User = url.UserPassword("oauth2", gitToken)
|
||||||
cloneURL = u.String()
|
cloneURL = u.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(logWriter, ">>> Cloning repository %s...\n", repoURL)
|
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.Dir = workDir
|
||||||
cloneCmd.Stdout = logWriter
|
cloneCmd.Stdout = logWriter
|
||||||
cloneCmd.Stderr = logWriter
|
cloneCmd.Stderr = logWriter
|
||||||
if err := cloneCmd.Run(); err != nil {
|
if err := cloneCmd.Run(); err != nil {
|
||||||
return "", "", fmt.Errorf("git clone failed: %w", err)
|
return "", "", fmt.Errorf("git clone failed: %w", err)
|
||||||
}
|
}
|
||||||
|
if targetCommit != "" && targetCommit != "HEAD" && targetCommit != "MANUAL" && targetCommit != "WEBHOOK" {
|
||||||
// Get commit hash
|
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 := exec.Command("git", "rev-parse", "HEAD")
|
||||||
commitCmd.Dir = workDir
|
commitCmd.Dir = workDir
|
||||||
commitHashBytes, err := commitCmd.Output()
|
commitHashBytes, err := commitCmd.Output()
|
||||||
@@ -55,14 +62,11 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC
|
|||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(logWriter, ">>> Failed to get commit hash: %v\n", err)
|
fmt.Fprintf(logWriter, ">>> Failed to get commit hash: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if runtime == "" {
|
if runtime == "" {
|
||||||
runtime = "nodejs"
|
runtime = "nodejs"
|
||||||
}
|
}
|
||||||
|
|
||||||
var nixPkgs string
|
var nixPkgs string
|
||||||
var defaultInstall, defaultBuild, defaultStart string
|
var defaultInstall, defaultBuild, defaultStart string
|
||||||
|
|
||||||
switch runtime {
|
switch runtime {
|
||||||
case "bun":
|
case "bun":
|
||||||
nixPkgs = `["bun"]`
|
nixPkgs = `["bun"]`
|
||||||
@@ -85,66 +89,50 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC
|
|||||||
defaultBuild = "npm run build"
|
defaultBuild = "npm run build"
|
||||||
defaultStart = "npm run start"
|
defaultStart = "npm run start"
|
||||||
}
|
}
|
||||||
|
|
||||||
installStr := defaultInstall
|
installStr := defaultInstall
|
||||||
if installCmd != "" {
|
if installCmd != "" {
|
||||||
installStr = installCmd
|
installStr = installCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
buildStr := defaultBuild
|
buildStr := defaultBuild
|
||||||
if buildCmd != "" {
|
if buildCmd != "" {
|
||||||
buildStr = buildCmd
|
buildStr = buildCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
startStr := defaultStart
|
startStr := defaultStart
|
||||||
if startCmd != "" {
|
if startCmd != "" {
|
||||||
startStr = startCmd
|
startStr = startCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
nixpacksConfig := fmt.Sprintf(`
|
nixpacksConfig := fmt.Sprintf(`
|
||||||
[phases.setup]
|
[phases.setup]
|
||||||
nixPkgs = %s
|
nixPkgs = %s
|
||||||
|
|
||||||
[phases.install]
|
[phases.install]
|
||||||
cmds = ["%s"]
|
cmds = ["%s"]
|
||||||
|
|
||||||
[phases.build]
|
[phases.build]
|
||||||
cmds = ["%s"]
|
cmds = ["%s"]
|
||||||
|
|
||||||
[start]
|
[start]
|
||||||
cmd = "%s"
|
cmd = "%s"
|
||||||
`, nixPkgs, installStr, buildStr, startStr)
|
`, nixPkgs, installStr, buildStr, startStr)
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(workDir, "package.json")); err == nil {
|
if _, err := os.Stat(filepath.Join(workDir, "package.json")); err == nil {
|
||||||
configPath := filepath.Join(workDir, "nixpacks.toml")
|
configPath := filepath.Join(workDir, "nixpacks.toml")
|
||||||
if err := os.WriteFile(configPath, []byte(nixpacksConfig), 0644); err != nil {
|
if err := os.WriteFile(configPath, []byte(nixpacksConfig), 0644); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to write nixpacks.toml: %w", err)
|
return "", "", fmt.Errorf("failed to write nixpacks.toml: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
imageName := strings.ToLower(appName)
|
imageName := strings.ToLower(appName)
|
||||||
|
|
||||||
fmt.Fprintf(logWriter, "\n>>> Starting Nixpacks build for %s...\n", imageName)
|
fmt.Fprintf(logWriter, "\n>>> Starting Nixpacks build for %s...\n", imageName)
|
||||||
|
|
||||||
args := []string{"build", ".", "--name", imageName, "--no-cache"}
|
args := []string{"build", ".", "--name", imageName, "--no-cache"}
|
||||||
for k, v := range envVars {
|
for k, v := range envVars {
|
||||||
args = append(args, "--env", fmt.Sprintf("%s=%s", k, v))
|
args = append(args, "--env", fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
nixCmd := exec.Command("nixpacks", args...)
|
nixCmd := exec.Command("nixpacks", args...)
|
||||||
nixCmd.Dir = workDir
|
nixCmd.Dir = workDir
|
||||||
nixCmd.Stdout = logWriter
|
nixCmd.Stdout = logWriter
|
||||||
nixCmd.Stderr = logWriter
|
nixCmd.Stderr = logWriter
|
||||||
|
|
||||||
nixCmd.Env = append(os.Environ(),
|
nixCmd.Env = append(os.Environ(),
|
||||||
"NIXPACKS_NO_CACHE=1",
|
"NIXPACKS_NO_CACHE=1",
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := nixCmd.Run(); err != nil {
|
if err := nixCmd.Run(); err != nil {
|
||||||
return "", "", fmt.Errorf("nixpacks build failed: %w", err)
|
return "", "", fmt.Errorf("nixpacks build failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(logWriter, "\n>>> Build successful!\n")
|
fmt.Fprintf(logWriter, "\n>>> Build successful!\n")
|
||||||
|
|
||||||
return imageName, commitHash, nil
|
return imageName, commitHash, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
"clickploy/internal/models"
|
"clickploy/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var DB *gorm.DB
|
var DB *gorm.DB
|
||||||
|
|
||||||
func Init(storagePath string) {
|
func Init(storagePath string) {
|
||||||
var err error
|
var err error
|
||||||
dbPath := filepath.Join(storagePath, "clickploy.db")
|
dbPath := filepath.Join(storagePath, "clickploy.db")
|
||||||
|
|
||||||
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Info),
|
Logger: logger.Default.LogMode(logger.Info),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to connect to database:", err)
|
log.Fatal("Failed to connect to database:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Migrating database...")
|
log.Println("Migrating database...")
|
||||||
err = DB.AutoMigrate(&models.User{}, &models.Project{}, &models.Deployment{}, &models.EnvVar{}, &models.Database{})
|
err = DB.AutoMigrate(&models.User{}, &models.Project{}, &models.Deployment{}, &models.EnvVar{}, &models.Database{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
package deployer
|
package deployer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Deployer struct {
|
type Deployer struct {
|
||||||
cli *client.Client
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create docker client: %w", err)
|
return nil, fmt.Errorf("failed to create docker client: %w", err)
|
||||||
}
|
}
|
||||||
return &Deployer{cli: cli}, nil
|
return &Deployer{cli: cli}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Deployer) RunContainer(ctx context.Context, imageName, appName string, hostPort int, envVars []string) (string, error) {
|
func (d *Deployer) RunContainer(ctx context.Context, imageName, appName string, hostPort int, envVars []string) (string, error) {
|
||||||
config := &container.Config{
|
config := &container.Config{
|
||||||
Image: imageName,
|
Image: imageName,
|
||||||
@@ -31,7 +29,6 @@ func (d *Deployer) RunContainer(ctx context.Context, imageName, appName string,
|
|||||||
},
|
},
|
||||||
Env: envVars,
|
Env: envVars,
|
||||||
}
|
}
|
||||||
|
|
||||||
hostConfig := &container.HostConfig{
|
hostConfig := &container.HostConfig{
|
||||||
PortBindings: nat.PortMap{
|
PortBindings: nat.PortMap{
|
||||||
"3000/tcp": []nat.PortBinding{
|
"3000/tcp": []nat.PortBinding{
|
||||||
@@ -45,21 +42,96 @@ func (d *Deployer) RunContainer(ctx context.Context, imageName, appName string,
|
|||||||
Name: "unless-stopped",
|
Name: "unless-stopped",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = d.cli.ContainerRemove(ctx, appName, types.ContainerRemoveOptions{Force: true})
|
_ = d.cli.ContainerRemove(ctx, appName, types.ContainerRemoveOptions{Force: true})
|
||||||
|
|
||||||
resp, err := d.cli.ContainerCreate(ctx, config, hostConfig, nil, nil, appName)
|
resp, err := d.cli.ContainerCreate(ctx, config, hostConfig, nil, nil, appName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create container: %w", err)
|
return "", fmt.Errorf("failed to create container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
|
if err := d.cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
|
||||||
return "", fmt.Errorf("failed to start container: %w", err)
|
return "", fmt.Errorf("failed to start container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp.ID, nil
|
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) {
|
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})
|
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{})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `gorm:"primaryKey" json:"id"`
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -16,9 +13,9 @@ type User struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
APIKey string `json:"api_key" gorm:"uniqueIndex"`
|
||||||
Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"`
|
Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
ID string `gorm:"primaryKey" json:"id"`
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -37,14 +34,12 @@ type Project struct {
|
|||||||
Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"`
|
Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"`
|
||||||
EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"`
|
EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnvVar struct {
|
type EnvVar struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Deployment struct {
|
type Deployment struct {
|
||||||
ID string `gorm:"primaryKey" json:"id"`
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -57,7 +52,6 @@ type Deployment struct {
|
|||||||
Logs string `json:"logs"`
|
Logs string `json:"logs"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -65,4 +59,6 @@ type Database struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
SizeMB float64 `json:"size_mb"`
|
SizeMB float64 `json:"size_mb"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
ContainerID string `json:"container_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
package ports
|
package ports
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
startPort int
|
startPort int
|
||||||
endPort int
|
endPort int
|
||||||
allocations map[string]int
|
allocations map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(start, end int) *Manager {
|
func NewManager(start, end int) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
startPort: start,
|
startPort: start,
|
||||||
@@ -20,18 +18,15 @@ func NewManager(start, end int) *Manager {
|
|||||||
allocations: make(map[string]int),
|
allocations: make(map[string]int),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetPort(appName string, specificPort int) (int, error) {
|
func (m *Manager) GetPort(appName string, specificPort int) (int, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if port, exists := m.allocations[appName]; exists {
|
if port, exists := m.allocations[appName]; exists {
|
||||||
if specificPort > 0 && specificPort != port {
|
if specificPort > 0 && specificPort != port {
|
||||||
return 0, fmt.Errorf("app %s is already running on port %d", appName, port)
|
return 0, fmt.Errorf("app %s is already running on port %d", appName, port)
|
||||||
}
|
}
|
||||||
return port, nil
|
return port, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if specificPort > 0 {
|
if specificPort > 0 {
|
||||||
if err := m.checkPortAvailable(specificPort); err != nil {
|
if err := m.checkPortAvailable(specificPort); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -39,8 +34,10 @@ func (m *Manager) GetPort(appName string, specificPort int) (int, error) {
|
|||||||
m.allocations[appName] = specificPort
|
m.allocations[appName] = specificPort
|
||||||
return specificPort, nil
|
return specificPort, nil
|
||||||
}
|
}
|
||||||
|
rangeSize := m.endPort - m.startPort + 1
|
||||||
for port := m.startPort; port <= m.endPort; port++ {
|
offsets := rand.Perm(rangeSize)
|
||||||
|
for _, offset := range offsets {
|
||||||
|
port := m.startPort + offset
|
||||||
if err := m.checkPortAvailable(port); err == nil {
|
if err := m.checkPortAvailable(port); err == nil {
|
||||||
if !m.isPortAllocatedInternal(port) {
|
if !m.isPortAllocatedInternal(port) {
|
||||||
m.allocations[appName] = 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)
|
return 0, fmt.Errorf("no available ports in range %d-%d", m.startPort, m.endPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) isPortAllocatedInternal(port int) bool {
|
func (m *Manager) isPortAllocatedInternal(port int) bool {
|
||||||
for _, p := range m.allocations {
|
for _, p := range m.allocations {
|
||||||
if p == port {
|
if p == port {
|
||||||
@@ -60,16 +55,13 @@ func (m *Manager) isPortAllocatedInternal(port int) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) checkPortAvailable(port int) error {
|
func (m *Manager) checkPortAvailable(port int) error {
|
||||||
if port < m.startPort || port > m.endPort {
|
if port < m.startPort || port > m.endPort {
|
||||||
return fmt.Errorf("port %d is out of allowed range %d-%d", port, m.startPort, m.endPort)
|
return fmt.Errorf("port %d is out of allowed range %d-%d", port, m.startPort, m.endPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.isPortAllocatedInternal(port) {
|
if m.isPortAllocatedInternal(port) {
|
||||||
return fmt.Errorf("port %d is internally allocated", port)
|
return fmt.Errorf("port %d is internally allocated", port)
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf("localhost:%d", port)
|
addr := fmt.Sprintf("localhost:%d", port)
|
||||||
conn, err := net.Dial("tcp", addr)
|
conn, err := net.Dial("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ export interface Deployment {
|
|||||||
updated_at: string;
|
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 {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -43,6 +57,9 @@ export interface Project {
|
|||||||
webhook_secret: string;
|
webhook_secret: string;
|
||||||
git_token?: string;
|
git_token?: string;
|
||||||
runtime?: string;
|
runtime?: string;
|
||||||
|
build_command?: string;
|
||||||
|
start_command?: string;
|
||||||
|
install_command?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProject(id: string): Promise<Project | null> {
|
export async function getProject(id: string): Promise<Project | null> {
|
||||||
@@ -126,6 +143,18 @@ export async function createProject(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProject(id: string, data: Partial<Project>): Promise<Project | null> {
|
||||||
|
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<string, string>): Promise<boolean> {
|
export async function updateProjectEnv(id: string, envVars: Record<string, string>): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth(`/api/projects/${id}/env`, {
|
await fetchWithAuth(`/api/projects/${id}/env`, {
|
||||||
@@ -139,10 +168,11 @@ export async function updateProjectEnv(id: string, envVars: Record<string, strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function redeployProject(id: string): Promise<boolean> {
|
export async function redeployProject(id: string, commit?: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth(`/api/projects/${id}/redeploy`, {
|
await fetchWithAuth(`/api/projects/${id}/redeploy`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: JSON.stringify({ commit }),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e: any) {
|
} 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() {
|
export async function getAdminUsers() {
|
||||||
try {
|
try {
|
||||||
return await fetchWithAuth("/api/admin/users");
|
return await fetchWithAuth("/api/admin/users");
|
||||||
@@ -269,3 +311,78 @@ export async function getAdminStats() {
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
16
frontend/src/lib/components/ui/tabs/index.ts
Normal file
16
frontend/src/lib/components/ui/tabs/index.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
17
frontend/src/lib/components/ui/tabs/tabs-content.svelte
Normal file
17
frontend/src/lib/components/ui/tabs/tabs-content.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="tabs-content"
|
||||||
|
class={cn("flex-1 outline-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
frontend/src/lib/components/ui/tabs/tabs-list.svelte
Normal file
20
frontend/src/lib/components/ui/tabs/tabs-list.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.ListProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.List
|
||||||
|
bind:ref
|
||||||
|
data-slot="tabs-list"
|
||||||
|
class={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
frontend/src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
20
frontend/src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
19
frontend/src/lib/components/ui/tabs/tabs.svelte
Normal file
19
frontend/src/lib/components/ui/tabs/tabs.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(""),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
data-slot="tabs"
|
||||||
|
class={cn("flex flex-col gap-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -539,7 +539,7 @@
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription class="flex items-center gap-1" >
|
<CardDescription class="flex items-center gap-1" >
|
||||||
<Github class="h-3 w-3" />
|
<Github class="h-3 w-3" />
|
||||||
<a href={project.repo_url} target="_blank">{new URL(project.repo_url).pathname.slice(1)}</a>
|
<a class="underline" href={project.repo_url} target="_blank">{new URL(project.repo_url).pathname.slice(1)}</a>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -592,7 +592,7 @@
|
|||||||
<span class="font-medium text-foreground truncate">
|
<span class="font-medium text-foreground truncate">
|
||||||
{latestDeployment
|
{latestDeployment
|
||||||
? new Date(
|
? new Date(
|
||||||
latestDeployment.CreatedAt,
|
latestDeployment.created_at,
|
||||||
).toLocaleDateString()
|
).toLocaleDateString()
|
||||||
: "Never"}
|
: "Never"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { listProjects, type Project } from "$lib/api";
|
import {
|
||||||
|
listProjects,
|
||||||
|
listDatabases,
|
||||||
|
type Project,
|
||||||
|
type Database,
|
||||||
|
} from "$lib/api";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -23,14 +28,20 @@
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Globe,
|
Globe,
|
||||||
Server,
|
Server,
|
||||||
|
Database as DatabaseIcon,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
let projects = $state<Project[]>([]);
|
let projects = $state<Project[]>([]);
|
||||||
|
let databases = $state<Database[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const res = await listProjects();
|
const [projRes, dbRes] = await Promise.all([
|
||||||
if (res) projects = res;
|
listProjects(),
|
||||||
|
listDatabases(),
|
||||||
|
]);
|
||||||
|
if (projRes) projects = projRes;
|
||||||
|
if (dbRes) databases = dbRes;
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -55,18 +66,24 @@
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Active Services</CardTitle>
|
<CardTitle>Active Services</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Overview of deployed applications and their internal/external ports.
|
Overview of deployed applications and their
|
||||||
|
internal/external ports.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{#if projects.length === 0}
|
{#if projects.length === 0 && databases.length === 0}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center py-10 text-center"
|
class="flex flex-col items-center justify-center py-10 text-center"
|
||||||
>
|
>
|
||||||
<Network class="h-10 w-10 text-muted-foreground mb-4" />
|
<Network
|
||||||
<h3 class="text-lg font-medium">No services found</h3>
|
class="h-10 w-10 text-muted-foreground mb-4"
|
||||||
|
/>
|
||||||
|
<h3 class="text-lg font-medium">
|
||||||
|
No services found
|
||||||
|
</h3>
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Deploy a project to populate the network map.
|
Deploy a project or create a database to
|
||||||
|
populate the network map.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -78,15 +95,21 @@
|
|||||||
<TableHead>Port</TableHead>
|
<TableHead>Port</TableHead>
|
||||||
<TableHead>URL</TableHead>
|
<TableHead>URL</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead class="text-right">Action</TableHead>
|
<TableHead class="text-right"
|
||||||
|
>Action</TableHead
|
||||||
|
>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{#each projects as project}
|
{#each projects as project}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-medium">
|
<TableCell class="font-medium">
|
||||||
<div class="flex items-center gap-2">
|
<div
|
||||||
<Server class="h-4 w-4 text-muted-foreground" />
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Server
|
||||||
|
class="h-4 w-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
<a
|
<a
|
||||||
href={`/projects/${project.id}`}
|
href={`/projects/${project.id}`}
|
||||||
class="hover:underline"
|
class="hover:underline"
|
||||||
@@ -95,11 +118,15 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="font-mono text-xs">localhost</TableCell>
|
<TableCell class="font-mono text-xs"
|
||||||
|
>localhost</TableCell
|
||||||
|
>
|
||||||
<TableCell class="font-mono text-xs"
|
<TableCell class="font-mono text-xs"
|
||||||
>{project.port}</TableCell
|
>{project.port}</TableCell
|
||||||
>
|
>
|
||||||
<TableCell class="font-mono text-xs text-muted-foreground">
|
<TableCell
|
||||||
|
class="font-mono text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
http://localhost:{project.port}
|
http://localhost:{project.port}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -122,6 +149,47 @@
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each databases as db}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell class="font-medium">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DatabaseIcon
|
||||||
|
class="h-4 w-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="/storage"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
{db.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="font-mono text-xs"
|
||||||
|
>localhost</TableCell
|
||||||
|
>
|
||||||
|
<TableCell class="font-mono text-xs"
|
||||||
|
>{db.port}</TableCell
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
class="font-mono text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{db.type}://localhost:{db.port}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
|
||||||
|
bg-green-500/15 text-green-500 border-transparent capitalize"
|
||||||
|
>
|
||||||
|
{db.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right">
|
||||||
|
<!-- No external link action for now -->
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { getProject, type Project } from "$lib/api";
|
import { getProject, type Project, redeployProject } from "$lib/api";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -20,7 +20,10 @@
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
let project = $state<Project | null>(null);
|
let project = $state<Project | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -69,6 +72,21 @@
|
|||||||
return AlertCircle;
|
return AlertCircle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function handleRedeploy(commit?: string) {
|
||||||
|
if (!project) return;
|
||||||
|
toast.info(commit ? `Redeploying commit ${commit.substring(0, 7)}...` : "Starting redeployment...");
|
||||||
|
const success = await redeployProject(project.id.toString(), commit);
|
||||||
|
if (success) {
|
||||||
|
toast.success("Redeployment started!");
|
||||||
|
// Refresh project data to show new deployment
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (project) {
|
||||||
|
const res = await getProject(project.id);
|
||||||
|
if (res) project = res;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -84,6 +102,9 @@
|
|||||||
History of your application builds.
|
History of your application builds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onclick={() => handleRedeploy()}>
|
||||||
|
<Play class="mr-2 h-4 w-4" /> Redeploy
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="border-border/60">
|
<Card class="border-border/60">
|
||||||
@@ -130,6 +151,7 @@
|
|||||||
<GitCommit class="h-3.5 w-3.5 text-muted-foreground" />
|
<GitCommit class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span
|
<span
|
||||||
class="bg-muted px-2 py-0.5 rounded-md border border-border/50 text-foreground/80 font-mono"
|
class="bg-muted px-2 py-0.5 rounded-md border border-border/50 text-foreground/80 font-mono"
|
||||||
|
title={deployment.commit}
|
||||||
>
|
>
|
||||||
{deployment.commit === "HEAD"
|
{deployment.commit === "HEAD"
|
||||||
? "HEAD"
|
? "HEAD"
|
||||||
@@ -178,6 +200,17 @@
|
|||||||
>
|
>
|
||||||
<Terminal class="h-3.5 w-3.5" />
|
<Terminal class="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{#if deployment.commit !== "HEAD" && deployment.commit !== "MANUAL" && deployment.commit !== "WEBHOOK"}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
onclick={() => handleRedeploy(deployment.commit)}
|
||||||
|
title="Redeploy this version"
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { getProject, type Project, updateProjectEnv } from "$lib/api";
|
import {
|
||||||
|
getProject,
|
||||||
|
type Project,
|
||||||
|
updateProjectEnv,
|
||||||
|
updateProject,
|
||||||
|
getSystemStatus,
|
||||||
|
} from "$lib/api";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "$lib/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -28,22 +40,138 @@
|
|||||||
let project = $state<Project | null>(null);
|
let project = $state<Project | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let showSecret = $state(false);
|
let showSecret = $state(false);
|
||||||
|
let systemStatus = $state<{ local_ip: string; public_ip: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
let tempEnvVars = $state<{ key: string; value: string }[]>([]);
|
let tempEnvVars = $state<{ key: string; value: string }[]>([]);
|
||||||
let isDirty = $state(false);
|
let isDirty = $state(false);
|
||||||
|
let formLoading = $state(false);
|
||||||
|
|
||||||
|
let projectName = $state("");
|
||||||
|
let repoUrl = $state("");
|
||||||
|
let gitToken = $state("");
|
||||||
|
let runtime = $state("");
|
||||||
|
let installCmd = $state("");
|
||||||
|
let buildCmd = $state("");
|
||||||
|
let startCmd = $state("");
|
||||||
|
|
||||||
|
const runtimeConfig: Record<
|
||||||
|
string,
|
||||||
|
{ install: string; build: string; start: string }
|
||||||
|
> = {
|
||||||
|
nodejs: {
|
||||||
|
install: "npm install",
|
||||||
|
build: "npm run build",
|
||||||
|
start: "npm start",
|
||||||
|
},
|
||||||
|
bun: {
|
||||||
|
install: "bun install",
|
||||||
|
build: "bun run build",
|
||||||
|
start: "bun start",
|
||||||
|
},
|
||||||
|
python: {
|
||||||
|
install: "pip install -r requirements.txt",
|
||||||
|
build: "",
|
||||||
|
start: "python3 main.py",
|
||||||
|
},
|
||||||
|
go: {
|
||||||
|
install: "go mod download",
|
||||||
|
build: "go build -o main .",
|
||||||
|
start: "./main",
|
||||||
|
},
|
||||||
|
rust: {
|
||||||
|
install: "cargo fetch",
|
||||||
|
build: "cargo build --release",
|
||||||
|
start: "./target/release/main",
|
||||||
|
},
|
||||||
|
php: {
|
||||||
|
install: "composer install",
|
||||||
|
build: "",
|
||||||
|
start: "php -S 0.0.0.0:8080",
|
||||||
|
},
|
||||||
|
java: {
|
||||||
|
install: "mvn clean install",
|
||||||
|
build: "mvn package",
|
||||||
|
start: "java -jar target/app.jar",
|
||||||
|
},
|
||||||
|
static: {
|
||||||
|
install: "",
|
||||||
|
build: "",
|
||||||
|
start: "",
|
||||||
|
},
|
||||||
|
dockerfile: {
|
||||||
|
install: "",
|
||||||
|
build: "",
|
||||||
|
start: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let defaults = $derived(
|
||||||
|
runtimeConfig[runtime] || {
|
||||||
|
install: "npm install",
|
||||||
|
build: "npm run build",
|
||||||
|
start: "npm start",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const id = $page.params.id;
|
const id = $page.params.id;
|
||||||
if (id) {
|
const [projRes, sysRes] = await Promise.all([
|
||||||
const res = await getProject(id);
|
id ? getProject(id) : null,
|
||||||
if (res) {
|
getSystemStatus(),
|
||||||
project = res;
|
]);
|
||||||
|
|
||||||
|
if (projRes) {
|
||||||
|
project = projRes;
|
||||||
|
initFormData();
|
||||||
initEnvVars();
|
initEnvVars();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sysRes) {
|
||||||
|
systemStatus = sysRes;
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function initFormData() {
|
||||||
|
if (!project) return;
|
||||||
|
projectName = project.name;
|
||||||
|
repoUrl = project.repo_url;
|
||||||
|
runtime = project.runtime || "nodejs";
|
||||||
|
installCmd = project.install_command || "";
|
||||||
|
buildCmd = project.build_command || "";
|
||||||
|
startCmd = project.start_command || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
if (!project) return;
|
||||||
|
formLoading = true;
|
||||||
|
const res = await updateProject(project.id, {
|
||||||
|
name: projectName,
|
||||||
|
repo_url: repoUrl,
|
||||||
|
runtime,
|
||||||
|
install_command: installCmd,
|
||||||
|
build_command: buildCmd,
|
||||||
|
start_command: startCmd,
|
||||||
|
...(gitToken ? { git_token: gitToken } : {}),
|
||||||
|
});
|
||||||
|
formLoading = false;
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
project = res;
|
||||||
|
initFormData();
|
||||||
|
toast.success("Settings updated successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyId() {
|
||||||
|
if (project) {
|
||||||
|
navigator.clipboard.writeText(project.id);
|
||||||
|
toast.success("Project ID copied");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initEnvVars() {
|
function initEnvVars() {
|
||||||
if (project?.env_vars) {
|
if (project?.env_vars) {
|
||||||
tempEnvVars = project.env_vars.map((e) => ({
|
tempEnvVars = project.env_vars.map((e) => ({
|
||||||
@@ -139,7 +267,10 @@
|
|||||||
} else {
|
} else {
|
||||||
tempEnvVars = [
|
tempEnvVars = [
|
||||||
...tempEnvVars,
|
...tempEnvVars,
|
||||||
{ key: key.trim(), value: value.replace(/^["'](.*)["']$/, "$1") },
|
{
|
||||||
|
key: key.trim(),
|
||||||
|
value: value.replace(/^["'](.*)["']$/, "$1"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +287,196 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if project}
|
{:else if project}
|
||||||
<div class="space-y-6 max-w-4xl">
|
<div class="space-y-6 max-w-4xl">
|
||||||
|
<Card class="border-border/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-xl font-bold"
|
||||||
|
>Git Configuration</CardTitle
|
||||||
|
>
|
||||||
|
<CardDescription>
|
||||||
|
Connect your project to a Git repository.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Project Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
bind:value={projectName}
|
||||||
|
placeholder="my-awesome-project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="repo">Repository URL</Label>
|
||||||
|
<Input
|
||||||
|
id="repo"
|
||||||
|
bind:value={repoUrl}
|
||||||
|
placeholder="https://github.com/user/repo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="token">Git Token (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="token"
|
||||||
|
type="password"
|
||||||
|
bind:value={gitToken}
|
||||||
|
placeholder="Start typing to update..."
|
||||||
|
/>
|
||||||
|
<p class="text-[0.8rem] text-muted-foreground">
|
||||||
|
Leave empty to keep the existing token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter class="border-t px-4 flex justify-end">
|
||||||
|
<Button onclick={saveSettings} disabled={formLoading} size="sm">
|
||||||
|
{#if formLoading}
|
||||||
|
<Loader2 class="h-4 w-4 mr-2 animate-spin" /> Saving...
|
||||||
|
{:else}
|
||||||
|
<Save class="h-4 w-4 mr-2" /> Save Changes
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="border-border/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-xl font-bold"
|
||||||
|
>Build & Output Settings</CardTitle
|
||||||
|
>
|
||||||
|
<CardDescription>
|
||||||
|
Configure how your application is built and run.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="runtime">Runtime</Label>
|
||||||
|
<Select type="single" bind:value={runtime}>
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
{runtime
|
||||||
|
? runtime.charAt(0).toUpperCase() +
|
||||||
|
runtime.slice(1)
|
||||||
|
: "Select a runtime"}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="nodejs">Node.js</SelectItem>
|
||||||
|
<SelectItem value="bun">Bun</SelectItem>
|
||||||
|
<SelectItem value="python">Python</SelectItem>
|
||||||
|
<SelectItem value="go">Go</SelectItem>
|
||||||
|
<SelectItem value="rust">Rust</SelectItem>
|
||||||
|
<SelectItem value="php">PHP</SelectItem>
|
||||||
|
<SelectItem value="java">Java</SelectItem>
|
||||||
|
<SelectItem value="static">Static</SelectItem>
|
||||||
|
<SelectItem value="dockerfile"
|
||||||
|
>Dockerfile</SelectItem
|
||||||
|
>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="install">Install Command</Label>
|
||||||
|
<Input
|
||||||
|
id="install"
|
||||||
|
bind:value={installCmd}
|
||||||
|
placeholder={defaults.install}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="build">Build Command</Label>
|
||||||
|
<Input
|
||||||
|
id="build"
|
||||||
|
bind:value={buildCmd}
|
||||||
|
placeholder={defaults.build}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="start">Start Command</Label>
|
||||||
|
<Input
|
||||||
|
id="start"
|
||||||
|
bind:value={startCmd}
|
||||||
|
placeholder={defaults.start}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter class="border-t px-4 flex justify-end">
|
||||||
|
<Button onclick={saveSettings} disabled={formLoading} size="sm">
|
||||||
|
{#if formLoading}
|
||||||
|
<Loader2 class="h-4 w-4 mr-2 animate-spin" /> Saving...
|
||||||
|
{:else}
|
||||||
|
<Save class="h-4 w-4 mr-2" /> Save Changes
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="border-border/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-xl font-bold">Networking</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage network settings for your deployment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="port">Internal Port</Label>
|
||||||
|
<Input
|
||||||
|
id="port"
|
||||||
|
value={project.port.toString()}
|
||||||
|
readonly
|
||||||
|
class="bg-muted font-mono"
|
||||||
|
/>
|
||||||
|
<p class="text-[0.8rem] text-muted-foreground">
|
||||||
|
This port is assigned by the system and cannot be
|
||||||
|
changed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if systemStatus}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label>Local Network URL</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readonly
|
||||||
|
value={`http://${systemStatus.local_ip}:${project.port}`}
|
||||||
|
class="bg-muted font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() =>
|
||||||
|
window.open(
|
||||||
|
`http://${systemStatus!.local_ip}:${project!.port}`,
|
||||||
|
"_blank",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label>Public Network URL</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readonly
|
||||||
|
value={`http://${systemStatus.public_ip}:${project.port}`}
|
||||||
|
class="bg-muted font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() =>
|
||||||
|
window.open(
|
||||||
|
`http://${systemStatus!.public_ip}:${project!.port}`,
|
||||||
|
"_blank",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card class="border-border/60">
|
<Card class="border-border/60">
|
||||||
<CardHeader class="pb-4">
|
<CardHeader class="pb-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -163,11 +484,18 @@
|
|||||||
<CardTitle class="text-xl font-bold"
|
<CardTitle class="text-xl font-bold"
|
||||||
>Environment Variables</CardTitle
|
>Environment Variables</CardTitle
|
||||||
>
|
>
|
||||||
<CardDescription class="mt-1 text-sm text-muted-foreground">
|
<CardDescription
|
||||||
|
class="mt-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Configure runtime environment variables.
|
Configure runtime environment variables.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onclick={toggleSecret} class="h-8">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={toggleSecret}
|
||||||
|
class="h-8"
|
||||||
|
>
|
||||||
{#if showSecret}
|
{#if showSecret}
|
||||||
<EyeOff class="h-4 w-4" />
|
<EyeOff class="h-4 w-4" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -231,7 +559,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter class="border-t px-4 flex justify-end">
|
<CardFooter class="border-t px-4 flex justify-end">
|
||||||
<Button onclick={saveEnvVars} disabled={!isDirty || loading} size="sm">
|
<Button
|
||||||
|
onclick={saveEnvVars}
|
||||||
|
disabled={!isDirty || loading}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<Loader2 class="h-4 w-4 mr-2 animate-spin" /> Saving...
|
<Loader2 class="h-4 w-4 mr-2 animate-spin" /> Saving...
|
||||||
{:else}
|
{:else}
|
||||||
@@ -245,7 +577,8 @@
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-lg">Webhook Integration</CardTitle>
|
<CardTitle class="text-lg">Webhook Integration</CardTitle>
|
||||||
<CardDescription class="mt-1.5">
|
<CardDescription class="mt-1.5">
|
||||||
Trigger deployments automatically when you push to your repository.
|
Trigger deployments automatically when you push to your
|
||||||
|
repository.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
@@ -257,7 +590,11 @@
|
|||||||
value={`http://localhost:8080/projects/${project.id}/webhook/${project.webhook_secret}`}
|
value={`http://localhost:8080/projects/${project.id}/webhook/${project.webhook_secret}`}
|
||||||
class="bg-muted font-mono text-xs"
|
class="bg-muted font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="icon" onclick={copyWebhook}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={copyWebhook}
|
||||||
|
>
|
||||||
<Copy class="h-4 w-4" />
|
<Copy class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,7 +604,9 @@
|
|||||||
|
|
||||||
<Card class="border-destructive/50 bg-destructive/5">
|
<Card class="border-destructive/50 bg-destructive/5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-destructive text-lg">Danger Zone</CardTitle>
|
<CardTitle class="text-destructive text-lg"
|
||||||
|
>Danger Zone</CardTitle
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
144
frontend/src/routes/settings/session/+page.svelte
Normal file
144
frontend/src/routes/settings/session/+page.svelte
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { getProfile, regenerateAPIKey } from "$lib/api";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
} from "$lib/components/ui/card";
|
||||||
|
import { Loader2, Copy, RefreshCw, Terminal } from "@lucide/svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
let apiKey = $state("");
|
||||||
|
let regenerating = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const profile = await getProfile();
|
||||||
|
if (profile) {
|
||||||
|
apiKey = profile.api_key;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
if (
|
||||||
|
!confirm("Are you sure? This will invalidate your current API key.")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
regenerating = true;
|
||||||
|
const res = await regenerateAPIKey();
|
||||||
|
regenerating = false;
|
||||||
|
|
||||||
|
if (res && res.api_key) {
|
||||||
|
apiKey = res.api_key;
|
||||||
|
toast.success("API Key regenerated successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey() {
|
||||||
|
navigator.clipboard.writeText(apiKey);
|
||||||
|
toast.success("API Key copied to clipboard");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto py-10 px-4 max-w-4xl">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">
|
||||||
|
Session & API Access
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Manage your API keys for CLI and external access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center p-10">
|
||||||
|
<Loader2 class="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Card class="border-border/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Personal Access Token</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use this token to authenticate with the Clickploy CLI
|
||||||
|
and API. Treat this token like a password.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Your API Key</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readonly
|
||||||
|
value={apiKey}
|
||||||
|
class="font-mono bg-muted"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={copyKey}
|
||||||
|
>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-muted/50 p-4 rounded-lg space-y-2 border border-border/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Terminal class="h-4 w-4" />
|
||||||
|
CLI Usage
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-xs font-mono bg-background p-2 rounded border border-border/50"
|
||||||
|
>
|
||||||
|
clickploy login --token {apiKey}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Or use it in the Authorization header for API
|
||||||
|
requests:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="text-xs font-mono bg-background p-2 rounded border border-border/50"
|
||||||
|
>
|
||||||
|
Authorization: Bearer {apiKey}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter
|
||||||
|
class="border-t px-6 py-4 flex justify-between items-center bg-muted/40"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
Last generated: {new Date().toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onclick={handleRegenerate}
|
||||||
|
disabled={regenerating}
|
||||||
|
>
|
||||||
|
{#if regenerating}
|
||||||
|
<Loader2 class="h-3 w-3 mr-2 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<RefreshCw class="h-3 w-3 mr-2" />
|
||||||
|
{/if}
|
||||||
|
Regenerate Token
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,7 +5,21 @@
|
|||||||
Plus,
|
Plus,
|
||||||
Server,
|
Server,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Trash,
|
||||||
|
Copy,
|
||||||
|
Activity,
|
||||||
|
Calendar,
|
||||||
|
Cloud,
|
||||||
|
Box,
|
||||||
|
Check,
|
||||||
|
Power,
|
||||||
|
Play,
|
||||||
|
RotateCw,
|
||||||
} from "@lucide/svelte";
|
} 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 { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -13,10 +27,23 @@
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
import { Progress } from "$lib/components/ui/progress";
|
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 { onMount } from "svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
@@ -26,27 +53,76 @@
|
|||||||
totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0,
|
totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
let userDatabases = $state<any[]>([]);
|
let userDatabases = $state<DatabaseType[]>([]);
|
||||||
let loading = $state(true);
|
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<DatabaseType | null>(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 = [
|
const availableTypes = [
|
||||||
{
|
{
|
||||||
name: "SQLite",
|
name: "SQLite",
|
||||||
description: "Embedded, serverless database engine.",
|
description: "Embedded, serverless database engine.",
|
||||||
type: "sqlite",
|
type: "sqlite",
|
||||||
status: "Available",
|
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",
|
name: "PostgreSQL",
|
||||||
description: "Advanced open source relational database.",
|
description: "Advanced open source relational database.",
|
||||||
type: "postgres",
|
type: "postgres",
|
||||||
status: "Coming Soon",
|
status: "Coming Soon",
|
||||||
|
icon: Cloud,
|
||||||
|
color: "text-indigo-500",
|
||||||
|
bgColor: "bg-indigo-500/10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Redis",
|
name: "Redis",
|
||||||
description: "In-memory data structure store.",
|
description: "In-memory data structure store.",
|
||||||
type: "redis",
|
type: "redis",
|
||||||
status: "Coming Soon",
|
status: "Coming Soon",
|
||||||
|
icon: Activity,
|
||||||
|
color: "text-red-500",
|
||||||
|
bgColor: "bg-red-500/10",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -65,129 +141,693 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(type: string) {
|
function initiateCreate(type: string) {
|
||||||
const name = prompt("Enter database name:");
|
selectedDbType = type;
|
||||||
if (!name) return;
|
newDbName = "";
|
||||||
|
showCreateDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performCreate() {
|
||||||
|
if (!newDbName.trim()) return;
|
||||||
|
isCreating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res: any = await createDatabase(newDbName, selectedDbType);
|
||||||
|
showCreateDialog = false; // Close name input dialog
|
||||||
|
|
||||||
const res = await createDatabase(name, type);
|
|
||||||
if (res) {
|
if (res) {
|
||||||
toast.success("Database created successfully!");
|
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();
|
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);
|
onMount(loadData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto py-10 px-4">
|
<div class="container mx-auto py-8 px-4 max-w-7xl">
|
||||||
<div class="mb-8 flex items-center justify-between">
|
<div
|
||||||
|
class="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold tracking-tight">Storage</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Databases</h1>
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground mt-1">
|
||||||
Manage databases and view storage usage.
|
Manage your database instances and storage volume.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
|
||||||
<Plus class="mr-2 h-4 w-4" /> Create Database
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-3 mb-8">
|
<div class="grid gap-6 md:grid-cols-3 mb-10">
|
||||||
<Card class="md:col-span-3">
|
<Card
|
||||||
<CardHeader>
|
class="md:col-span-3 overflow-hidden border-l-4 border-l-primary/50"
|
||||||
<CardTitle class="flex items-center gap-2">
|
>
|
||||||
<HardDrive class="h-5 w-5" /> Storage Usage
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-lg">
|
||||||
|
<HardDrive class="h-5 w-5 text-primary" /> Storage Volume
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Total disk space used on the host machine.
|
Total disk space usage across all your deployments and
|
||||||
|
databases.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-end justify-between">
|
||||||
<span class="font-medium"
|
<div>
|
||||||
>{(usedStorage / 1024).toFixed(2)} GB used</span
|
<span class="text-2xl font-bold"
|
||||||
|
>{(usedStorage / 1024).toFixed(2)} GB</span
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground"
|
<span class="text-sm text-muted-foreground ml-1"
|
||||||
>{(totalStorage / 1024).toFixed(2)} GB total</span
|
>used of {(totalStorage / 1024).toFixed(2)} GB</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={usagePercent} class="h-2" />
|
<span class="font-medium text-sm text-muted-foreground"
|
||||||
<p class="text-xs text-muted-foreground">
|
>{usagePercent.toFixed(1)}%</span
|
||||||
You are using {usagePercent.toFixed(1)}% of available storage.
|
>
|
||||||
</p>
|
</div>
|
||||||
|
<Progress value={usagePercent} class="h-2.5 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold tracking-tight mb-4">Your Databases</h2>
|
<div class="space-y-6">
|
||||||
{#if userDatabases.length === 0}
|
|
||||||
<div
|
|
||||||
class="rounded-lg border border-dashed p-8 text-center text-muted-foreground mb-8"
|
|
||||||
>
|
|
||||||
No databases created yet.
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="grid gap-4 mb-8">
|
|
||||||
{#each userDatabases as db}
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between rounded-lg border p-4 bg-muted/20"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="rounded-full bg-blue-500/10 p-2">
|
|
||||||
<Database class="h-6 w-6 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">{db.name}</h3>
|
<h2
|
||||||
<p class="text-sm text-muted-foreground uppercase">
|
class="text-xl font-semibold tracking-tight mb-4 flex items-center gap-2"
|
||||||
{db.type} • {new Date(db.CreatedAt).toLocaleDateString()}
|
>
|
||||||
|
<Server class="w-5 h-5 text-muted-foreground" />
|
||||||
|
Active Databases
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<div
|
||||||
|
class="h-32 rounded-lg bg-muted/20 animate-pulse"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if userDatabases.length === 0}
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-dashed p-10 text-center bg-muted/10"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted"
|
||||||
|
>
|
||||||
|
<Database class="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-4 text-lg font-semibold">No databases yet</h3>
|
||||||
|
<p class="mb-4 text-sm text-muted-foreground">
|
||||||
|
Create your first database to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
<span
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
class="text-xs font-medium px-2.5 py-0.5 rounded-full bg-green-500/15 text-green-500"
|
{#each userDatabases as db}
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-between p-4 rounded-xl border bg-card hover:shadow-md transition-all group text-left relative overflow-hidden"
|
||||||
|
onclick={() => handleCardClick(db)}
|
||||||
|
disabled={db.type !== "mongodb"}
|
||||||
>
|
>
|
||||||
{db.status}
|
<div class="flex items-center gap-4">
|
||||||
</span>
|
<div
|
||||||
|
class={`rounded-full p-2.5 ${
|
||||||
|
db.type === "sqlite"
|
||||||
|
? "bg-blue-500/10"
|
||||||
|
: db.type === "mongodb"
|
||||||
|
? "bg-green-500/10"
|
||||||
|
: "bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{#if db.type === "sqlite"}
|
||||||
|
<Box class="h-5 w-5 text-blue-500" />
|
||||||
|
{:else if db.type === "mongodb"}
|
||||||
|
<Database
|
||||||
|
class="h-5 w-5 text-green-500"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Database
|
||||||
|
class="h-5 w-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-base">
|
||||||
|
{db.name}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span class="capitalize">{db.type}</span
|
||||||
|
>
|
||||||
|
<span>•</span>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
class={`h-2 w-2 rounded-full ${db.status === "running" || db.status === "Available" ? "bg-green-500" : "bg-yellow-500"}`}
|
||||||
|
></div>
|
||||||
|
<span class="capitalize"
|
||||||
|
>{db.status}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if db.port > 0}
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="font-mono"
|
||||||
|
>
|
||||||
|
:{db.port}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-muted-foreground hover:text-red-500 z-10"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(db.ID);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold tracking-tight mb-4">Create New</h2>
|
<div class="pt-8">
|
||||||
<div class="grid gap-4">
|
<h2
|
||||||
|
class="text-xl font-semibold tracking-tight mb-4 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus class="w-5 h-5 text-muted-foreground" />
|
||||||
|
Create New Database
|
||||||
|
</h2>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{#each availableTypes as db}
|
{#each availableTypes as db}
|
||||||
<div
|
<button
|
||||||
class="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/50 transition-colors"
|
class="flex flex-col items-start text-left rounded-xl border p-4 hover:bg-muted/30 hover:border-primary/50 transition-all disabled:opacity-50 disabled:pointer-events-none group"
|
||||||
>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="rounded-full bg-primary/10 p-2">
|
|
||||||
<Server class="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold">{db.name}</h3>
|
|
||||||
<p class="text-sm text-muted-foreground">{db.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium px-2.5 py-0.5 rounded-full {db.status ===
|
|
||||||
'Available'
|
|
||||||
? 'bg-green-500/15 text-green-500'
|
|
||||||
: 'bg-yellow-500/15 text-yellow-500'}"
|
|
||||||
>
|
|
||||||
{db.status}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={db.status !== "Available"}
|
disabled={db.status !== "Available"}
|
||||||
onclick={() => handleCreate(db.type)}
|
onclick={() => initiateCreate(db.type)}
|
||||||
>
|
>
|
||||||
Create
|
<div
|
||||||
</Button>
|
class="flex w-full items-center justify-between mb-3"
|
||||||
|
>
|
||||||
|
<div class={`rounded-full p-2.5 ${db.bgColor}`}>
|
||||||
|
<db.icon class={`h-5 w-5 ${db.color}`} />
|
||||||
</div>
|
</div>
|
||||||
|
{#if db.status === "Available"}
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="bg-primary/10 text-primary hover:bg-primary/20"
|
||||||
|
>Available</Badge
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="text-muted-foreground">Soon</Badge
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<h3 class="font-semibold text-lg">{db.name}</h3>
|
||||||
|
<p
|
||||||
|
class="text-sm text-muted-foreground mt-1 leading-snug"
|
||||||
|
>
|
||||||
|
{db.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Creation Dialog -->
|
||||||
|
<Dialog.Root bind:open={showCreateDialog}>
|
||||||
|
<Dialog.Content class="sm:max-w-[425px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title
|
||||||
|
>Create {availableTypes.find((t) => t.type === selectedDbType)
|
||||||
|
?.name} Database</Dialog.Title
|
||||||
|
>
|
||||||
|
<Dialog.Description>
|
||||||
|
Enter a unique name for your new database instance.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label for="name" class="text-right">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
bind:value={newDbName}
|
||||||
|
placeholder="my-database-1"
|
||||||
|
class="col-span-3"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (showCreateDialog = false)}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onclick={performCreate}
|
||||||
|
disabled={isCreating || !newDbName.trim()}
|
||||||
|
>
|
||||||
|
{isCreating ? "Creating..." : "Create Database"}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Credentials Dialog -->
|
||||||
|
<Dialog.Root bind:open={showCredsDialog}>
|
||||||
|
<Dialog.Content class="sm:max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
|
<Check class="h-5 w-5 text-green-500" /> Database Created
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Your database is ready. These variables are only shown once, so
|
||||||
|
please copy them now.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<div class="flex flex-col space-y-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Connection URI</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readonly
|
||||||
|
value={newCreds.uri}
|
||||||
|
class="font-mono bg-muted text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => copyToClipboard(newCreds.uri)}
|
||||||
|
>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readonly
|
||||||
|
value={newCreds.username}
|
||||||
|
class="font-mono bg-muted text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => copyToClipboard(newCreds.username)}
|
||||||
|
>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Password</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readonly
|
||||||
|
value={newCreds.password}
|
||||||
|
type="password"
|
||||||
|
class="font-mono bg-muted text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => copyToClipboard(newCreds.password)}
|
||||||
|
>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-md bg-yellow-500/10 p-4 border border-yellow-500/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<AlertCircle class="h-5 w-5 text-yellow-500 mt-0.5" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium text-yellow-500">
|
||||||
|
Important
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-sm text-yellow-600/90 dark:text-yellow-500/90"
|
||||||
|
>
|
||||||
|
This database is accessible via port <span
|
||||||
|
class="font-mono font-bold"
|
||||||
|
>{newCreds.port}</span
|
||||||
|
>. Make sure to allow this port in your firewall if
|
||||||
|
accessing externally.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button onclick={() => (showCredsDialog = false)}>Done</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Management Dialog -->
|
||||||
|
<Dialog.Root bind:open={showManageDialog}>
|
||||||
|
<Dialog.Content class="sm:max-w-[600px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2 text-xl">
|
||||||
|
<Database class="h-5 w-5 text-primary" /> Manage {manageDb?.name}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Configure connection details, credentials, and instance power
|
||||||
|
state.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if manageCreds.loading}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Tabs.Root value="connection" class="w-full">
|
||||||
|
<Tabs.List class="grid w-full grid-cols-2">
|
||||||
|
<Tabs.Trigger value="connection">Connection</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="settings"
|
||||||
|
>Settings & Danger</Tabs.Trigger
|
||||||
|
>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content value="connection" class="space-y-4 py-4">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border bg-card text-card-foreground shadow-sm p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="font-medium leading-none">Status</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Current state of the database container.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={manageDb?.status === "running"
|
||||||
|
? "default"
|
||||||
|
: "secondary"}
|
||||||
|
class={`${manageDb?.status === "running" ? "bg-green-500 hover:bg-green-600" : ""}`}
|
||||||
|
>
|
||||||
|
{manageDb?.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Connection string</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
readonly
|
||||||
|
value={manageCreds.uri}
|
||||||
|
class="pr-10 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground"
|
||||||
|
onclick={() =>
|
||||||
|
copyToClipboard(manageCreds.uri)}
|
||||||
|
>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Host</Label>
|
||||||
|
<div
|
||||||
|
class="p-2 rounded-md border bg-muted font-mono text-sm"
|
||||||
|
>
|
||||||
|
localhost
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Port</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
class="font-mono text-sm"
|
||||||
|
bind:value={manageCreds.port}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onclick={performUpdateCreds}
|
||||||
|
disabled={isUpdatingCreds}
|
||||||
|
>
|
||||||
|
{isUpdatingCreds
|
||||||
|
? "Saving Changes..."
|
||||||
|
: "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="settings" class="space-y-4 py-4">
|
||||||
|
<div class="rounded-lg border p-4 space-y-4">
|
||||||
|
<h3 class="font-medium text-sm">Credentials</h3>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<Input
|
||||||
|
bind:value={manageCreds.username}
|
||||||
|
class="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label>Password</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
bind:value={manageCreds.password}
|
||||||
|
type="text"
|
||||||
|
class="pr-10 font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="absolute right-0 top-0 h-full px-3"
|
||||||
|
onclick={() =>
|
||||||
|
copyToClipboard(
|
||||||
|
manageCreds.password,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onclick={performUpdateCreds}
|
||||||
|
disabled={isUpdatingCreds}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
Update Credentials
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/10 p-4 space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
class="font-medium text-sm text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Power Actions
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-sm text-red-600/70 dark:text-red-400/70"
|
||||||
|
>
|
||||||
|
Manage the running state of your database.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="border-red-200 hover:bg-red-100 hover:text-red-600 dark:border-red-900 dark:hover:bg-red-900/30"
|
||||||
|
disabled={isPowerAction ||
|
||||||
|
manageDb?.status === "stopped"}
|
||||||
|
onclick={performStopDatabase}
|
||||||
|
>
|
||||||
|
<Power class="h-4 w-4 mr-2" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="border-red-200 hover:bg-red-100 hover:text-red-600 dark:border-red-900 dark:hover:bg-red-900/30"
|
||||||
|
disabled={isPowerAction}
|
||||||
|
onclick={performRestartDatabase}
|
||||||
|
>
|
||||||
|
{#if manageDb?.status === "stopped"}
|
||||||
|
<Play class="h-4 w-4 mr-2" /> Start
|
||||||
|
{:else}
|
||||||
|
<RotateCw class="h-4 w-4 mr-2" /> Restart
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
{/if}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|||||||
Reference in New Issue
Block a user