Admin Dashboard, Webdocs, LICENSE, webhook, and ID's update.
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sir Blob
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
13
README.md
13
README.md
@@ -7,6 +7,9 @@ A minimal, self-hosted Platform as a Service (PaaS) for building and deploying a
|
||||
- **Docker-based**: Each deployment runs in an isolated Docker container.
|
||||
- **Real-time Logs**: View build and runtime logs streamed directly to the dashboard.
|
||||
- **Port Management**: Automatically assigns and manages ports for your applications.
|
||||
- **Deployment History**: Track every build with detailed commit info and status.
|
||||
- **Environment Variables**: Securely manage runtime configuration.
|
||||
- **Manual Redeploy**: Trigger rebuilds with a single click.
|
||||
- **Zero Configuration**: Just provide a repo URL, and Clickploy handles the rest.
|
||||
|
||||
## Tech Stack
|
||||
@@ -15,7 +18,17 @@ A minimal, self-hosted Platform as a Service (PaaS) for building and deploying a
|
||||
- **Build Engine**: Nixpacks
|
||||
- **Database**: SQLite (Embedded)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- Go 1.21+ (for development)
|
||||
- Node.js 20+ (for development)
|
||||
|
||||
### Running Locally
|
||||
1. Clone the repository.
|
||||
2. Run `docker-compose up --build`.
|
||||
3. Access the dashboard at `http://localhost:5173`.
|
||||
|
||||
## Architecture
|
||||
Clickploy acts as a control plane for your deployments. It clones your repository, builds a Docker image using Nixpacks, and spins up a container. It manages a persistent database of projects and deployments, ensuring state is maintained across restarts.
|
||||
|
||||
@@ -16,6 +16,8 @@ This service handles:
|
||||
### API Endpoints
|
||||
- `GET /api/projects`: List projects
|
||||
- `POST /api/projects`: Create a project
|
||||
- `PUT /api/projects/:id/env`: Update environment variables
|
||||
- `POST /api/projects/:id/redeploy`: Manual redeploy
|
||||
- `GET /api/activity`: Get recent activity
|
||||
- `WS /api/deployments/:id/logs/stream`: Stream logs
|
||||
|
||||
@@ -26,3 +28,4 @@ This service handles:
|
||||
- `internal/models/`: GORM models.
|
||||
- `internal/builder/`: Nixpacks wrapper.
|
||||
- `internal/deployer/`: Docker SDK wrapper.
|
||||
- `internal/ports/`: Port allocation logic.
|
||||
|
||||
@@ -43,6 +43,7 @@ func main() {
|
||||
handler.RegisterWebhookRoutes(r)
|
||||
handler.RegisterSystemRoutes(r)
|
||||
handler.RegisterStorageRoutes(r)
|
||||
handler.RegisterAdminRoutes(r)
|
||||
|
||||
log.Println("Starting Clickploy Backend on :8080")
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
|
||||
@@ -38,6 +38,7 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
|
||||
@@ -73,6 +73,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
@@ -111,6 +113,7 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
|
||||
53
backend/internal/api/admin.go
Normal file
53
backend/internal/api/admin.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"clickploy/internal/db"
|
||||
"clickploy/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handler) RegisterAdminRoutes(r *gin.Engine) {
|
||||
admin := r.Group("/api/admin", AuthMiddleware(), AdminMiddleware())
|
||||
{
|
||||
admin.GET("/users", h.adminListUsers)
|
||||
admin.DELETE("/users/:id", h.adminDeleteUser)
|
||||
admin.GET("/stats", h.adminGetStats)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) adminListUsers(c *gin.Context) {
|
||||
var users []models.User
|
||||
if err := db.DB.Preload("Projects").Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (h *Handler) adminDeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := db.DB.Where("id = ?", id).Delete(&models.User{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) adminGetStats(c *gin.Context) {
|
||||
var userCount int64
|
||||
var projectCount int64
|
||||
var deploymentCount int64
|
||||
|
||||
db.DB.Model(&models.User{}).Count(&userCount)
|
||||
db.DB.Model(&models.Project{}).Count(&projectCount)
|
||||
db.DB.Model(&models.Deployment{}).Count(&deploymentCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": userCount,
|
||||
"projects": projectCount,
|
||||
"deployments": deploymentCount,
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (h *Handler) handleDeploy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
imageName, err := h.builder.Build(req.Repo, req.Name, req.GitToken, "", "", "", "", nil, os.Stdout)
|
||||
imageName, _, err := h.builder.Build(req.Repo, req.Name, req.GitToken, "", "", "", "", nil, os.Stdout)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Build failed: %v", err)})
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"clickploy/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
|
||||
type AuthRequest struct {
|
||||
@@ -42,11 +43,17 @@ func (h *Handler) register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.DB.Model(&models.User{}).Count(&count)
|
||||
|
||||
userID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||
user := models.User{
|
||||
ID: userID,
|
||||
Email: req.Email,
|
||||
Password: hashed,
|
||||
Name: req.Name,
|
||||
Avatar: "https://github.com/shadcn.png",
|
||||
IsAdmin: count == 0,
|
||||
}
|
||||
|
||||
if result := db.DB.Create(&user); result.Error != nil {
|
||||
@@ -101,7 +108,7 @@ func (h *Handler) updateProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if result := db.DB.First(&user, userID); 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"})
|
||||
return
|
||||
}
|
||||
@@ -130,7 +137,7 @@ func (h *Handler) updatePassword(c *gin.Context) {
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if result := db.DB.First(&user, userID); 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"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"clickploy/internal/auth"
|
||||
"clickploy/internal/db"
|
||||
"clickploy/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -31,3 +33,29 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin privileges required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"clickploy/internal/auth"
|
||||
"clickploy/internal/db"
|
||||
"clickploy/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -40,14 +40,8 @@ func (h *Handler) updateProjectEnv(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
pid, err := strconv.ParseUint(projectID, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var project models.Project
|
||||
if result := db.DB.Where("id = ? AND owner_id = ?", pid, 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"})
|
||||
return
|
||||
}
|
||||
@@ -80,19 +74,15 @@ func (h *Handler) redeployProject(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
projectID := c.Param("id")
|
||||
|
||||
pid, err := strconv.ParseUint(projectID, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var project models.Project
|
||||
if result := db.DB.Preload("EnvVars").Where("id = ? AND owner_id = ?", pid, 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"})
|
||||
return
|
||||
}
|
||||
|
||||
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||
deployment := models.Deployment{
|
||||
ID: depID,
|
||||
ProjectID: project.ID,
|
||||
Status: "building",
|
||||
Commit: "MANUAL",
|
||||
@@ -110,7 +100,7 @@ func (h *Handler) redeployProject(c *gin.Context) {
|
||||
envMap[env.Key] = env.Value
|
||||
}
|
||||
|
||||
imageName, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi)
|
||||
imageName, commitHash, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi)
|
||||
deployment.Logs = logBuffer.String()
|
||||
|
||||
if err != nil {
|
||||
@@ -120,6 +110,11 @@ func (h *Handler) redeployProject(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update commit hash if we got one
|
||||
if commitHash != "" {
|
||||
deployment.Commit = commitHash
|
||||
}
|
||||
|
||||
var envStrings []string
|
||||
for _, env := range project.EnvVars {
|
||||
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
|
||||
@@ -190,12 +185,17 @@ func (h *Handler) createProject(c *gin.Context) {
|
||||
envVarsModel = append(envVarsModel, models.EnvVar{Key: k, Value: v})
|
||||
}
|
||||
|
||||
webhookSecret, _ := auth.HashPassword(fmt.Sprintf("%s-%d-%d", req.Name, userID, time.Now().UnixNano()))
|
||||
secretBytes := make([]byte, 16)
|
||||
rand.Read(secretBytes)
|
||||
webhookSecret := hex.EncodeToString(secretBytes)
|
||||
|
||||
id, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||
|
||||
project := models.Project{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
RepoURL: req.Repo,
|
||||
OwnerID: userID.(uint),
|
||||
OwnerID: userID.(string),
|
||||
Port: port,
|
||||
WebhookSecret: webhookSecret,
|
||||
GitToken: req.GitToken,
|
||||
@@ -211,7 +211,9 @@ func (h *Handler) createProject(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||
deployment := models.Deployment{
|
||||
ID: depID,
|
||||
ProjectID: project.ID,
|
||||
Status: "building",
|
||||
Commit: "HEAD",
|
||||
@@ -224,7 +226,7 @@ func (h *Handler) createProject(c *gin.Context) {
|
||||
streamer := &StreamWriter{DeploymentID: deployment.ID}
|
||||
multi := io.MultiWriter(&logBuffer, streamer)
|
||||
|
||||
imageName, err := h.builder.Build(req.Repo, req.Name, req.GitToken, req.BuildCommand, req.StartCommand, req.InstallCommand, req.Runtime, req.EnvVars, multi)
|
||||
imageName, commitHash, err := h.builder.Build(req.Repo, req.Name, req.GitToken, req.BuildCommand, req.StartCommand, req.InstallCommand, req.Runtime, req.EnvVars, multi)
|
||||
deployment.Logs = logBuffer.String()
|
||||
|
||||
if err != nil {
|
||||
@@ -234,6 +236,11 @@ func (h *Handler) createProject(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update commit hash
|
||||
if commitHash != "" {
|
||||
deployment.Commit = commitHash
|
||||
}
|
||||
|
||||
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, envStrings)
|
||||
if err != nil {
|
||||
deployment.Status = "failed"
|
||||
@@ -266,16 +273,10 @@ func (h *Handler) getProject(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
projectID := c.Param("id")
|
||||
|
||||
pid, err := strconv.ParseUint(projectID, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var project models.Project
|
||||
if result := db.DB.Order("created_at desc").Preload("Deployments", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("deployments.created_at desc")
|
||||
}).Preload("EnvVars").Where("id = ? AND owner_id = ?", pid, userID).First(&project); result.Error != nil {
|
||||
}).Preload("EnvVars").Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (h *Handler) handleGetStorageStats(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleListDatabases(c *gin.Context) {
|
||||
userId := c.GetUint("userID")
|
||||
userId := c.GetString("userID")
|
||||
var dbs []models.Database
|
||||
if err := db.DB.Where("owner_id = ?", userId).Find(&dbs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list databases"})
|
||||
@@ -63,7 +63,7 @@ func (h *Handler) handleListDatabases(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
||||
userId := c.GetUint("userID")
|
||||
userId := c.GetString("userID")
|
||||
var req CreateDatabaseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -81,7 +81,7 @@ func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
||||
dataDir := "./data/user_dbs"
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
|
||||
dbPath := filepath.Join(dataDir, fmt.Sprintf("%d_%s.db", userId, req.Name))
|
||||
dbPath := filepath.Join(dataDir, fmt.Sprintf("%s_%s.db", userId, req.Name))
|
||||
file, err := os.Create(dbPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database file"})
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -16,14 +15,14 @@ var upgrader = websocket.Upgrader{
|
||||
|
||||
type LogHub struct {
|
||||
mu sync.Mutex
|
||||
streams map[uint][]chan []byte
|
||||
streams map[string][]chan []byte
|
||||
}
|
||||
|
||||
var Hub = &LogHub{
|
||||
streams: make(map[uint][]chan []byte),
|
||||
streams: make(map[string][]chan []byte),
|
||||
}
|
||||
|
||||
func (h *LogHub) Broadcast(deploymentID uint, p []byte) {
|
||||
func (h *LogHub) Broadcast(deploymentID string, p []byte) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if sinks, ok := h.streams[deploymentID]; ok {
|
||||
@@ -36,7 +35,7 @@ func (h *LogHub) Broadcast(deploymentID uint, p []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LogHub) Subscribe(deploymentID uint) chan []byte {
|
||||
func (h *LogHub) Subscribe(deploymentID string) chan []byte {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
ch := make(chan []byte, 256)
|
||||
@@ -44,7 +43,7 @@ func (h *LogHub) Subscribe(deploymentID uint) chan []byte {
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *LogHub) Unsubscribe(deploymentID uint, ch chan []byte) {
|
||||
func (h *LogHub) Unsubscribe(deploymentID string, ch chan []byte) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if sinks, ok := h.streams[deploymentID]; ok {
|
||||
@@ -62,7 +61,7 @@ func (h *LogHub) Unsubscribe(deploymentID uint, ch chan []byte) {
|
||||
}
|
||||
|
||||
type StreamWriter struct {
|
||||
DeploymentID uint
|
||||
DeploymentID string
|
||||
}
|
||||
|
||||
func (w *StreamWriter) Write(p []byte) (n int, err error) {
|
||||
@@ -73,9 +72,8 @@ func (w *StreamWriter) Write(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
func (h *Handler) streamDeploymentLogs(c *gin.Context) {
|
||||
deploymentIDStr := c.Param("id")
|
||||
deploymentID, err := strconv.ParseUint(deploymentIDStr, 10, 64)
|
||||
if err != nil {
|
||||
deploymentID := c.Param("id")
|
||||
if deploymentID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
@@ -86,8 +84,8 @@ func (h *Handler) streamDeploymentLogs(c *gin.Context) {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
logChan := Hub.Subscribe(uint(deploymentID))
|
||||
defer Hub.Unsubscribe(uint(deploymentID), logChan)
|
||||
logChan := Hub.Subscribe(deploymentID)
|
||||
defer Hub.Unsubscribe(deploymentID, logChan)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
|
||||
@@ -5,38 +5,41 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"clickploy/internal/db"
|
||||
"clickploy/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
|
||||
func (h *Handler) RegisterWebhookRoutes(r *gin.Engine) {
|
||||
r.POST("/webhooks/trigger", h.handleWebhook)
|
||||
r.POST("/projects/:projectID/webhook/:webhookID", h.handleWebhook)
|
||||
}
|
||||
|
||||
func (h *Handler) handleWebhook(c *gin.Context) {
|
||||
projectIDHex := c.Query("project_id")
|
||||
if projectIDHex == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project_id required"})
|
||||
return
|
||||
}
|
||||
projectID := c.Param("projectID")
|
||||
webhookSecret := c.Param("webhookID")
|
||||
|
||||
pid, err := strconv.ParseUint(projectIDHex, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
|
||||
if projectID == "" || webhookSecret == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook url"})
|
||||
return
|
||||
}
|
||||
|
||||
var project models.Project
|
||||
if result := db.DB.Preload("EnvVars").First(&project, pid); 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"})
|
||||
return
|
||||
}
|
||||
|
||||
if project.WebhookSecret != webhookSecret {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Webhook Secret"})
|
||||
return
|
||||
}
|
||||
|
||||
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||
deployment := models.Deployment{
|
||||
ID: depID,
|
||||
ProjectID: project.ID,
|
||||
Status: "building",
|
||||
Commit: "WEBHOOK",
|
||||
@@ -57,7 +60,7 @@ func (h *Handler) handleWebhook(c *gin.Context) {
|
||||
envMap[env.Key] = env.Value
|
||||
}
|
||||
|
||||
imageName, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi)
|
||||
imageName, commitHash, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi)
|
||||
deployment.Logs = logBuffer.String()
|
||||
if err != nil {
|
||||
deployment.Status = "failed"
|
||||
@@ -66,6 +69,10 @@ func (h *Handler) handleWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if commitHash != "" {
|
||||
deployment.Commit = commitHash
|
||||
}
|
||||
|
||||
var envStrings []string
|
||||
for _, env := range project.EnvVars {
|
||||
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
var SecretKey = []byte("super-secret-key-change-me")
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func GenerateToken(userID uint, email string) (string, error) {
|
||||
func GenerateToken(userID string, email string) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
|
||||
@@ -16,20 +16,20 @@ func NewBuilder() *Builder {
|
||||
return &Builder{}
|
||||
}
|
||||
|
||||
func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installCmd, runtime string, envVars map[string]string, logWriter io.Writer) (string, 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)
|
||||
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)
|
||||
}
|
||||
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
|
||||
if gitToken != "" {
|
||||
u, err := url.Parse(repoURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid repo url: %w", err)
|
||||
return "", "", fmt.Errorf("invalid repo url: %w", err)
|
||||
}
|
||||
u.User = url.UserPassword("oauth2", gitToken)
|
||||
cloneURL = u.String()
|
||||
@@ -41,7 +41,19 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC
|
||||
cloneCmd.Stdout = logWriter
|
||||
cloneCmd.Stderr = logWriter
|
||||
if err := cloneCmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("git clone failed: %w", err)
|
||||
return "", "", fmt.Errorf("git clone failed: %w", err)
|
||||
}
|
||||
|
||||
// Get commit hash
|
||||
commitCmd := exec.Command("git", "rev-parse", "HEAD")
|
||||
commitCmd.Dir = workDir
|
||||
commitHashBytes, err := commitCmd.Output()
|
||||
commitHash := ""
|
||||
if err == nil {
|
||||
commitHash = strings.TrimSpace(string(commitHashBytes))
|
||||
fmt.Fprintf(logWriter, ">>> Checked out commit: %s\n", commitHash)
|
||||
} else {
|
||||
fmt.Fprintf(logWriter, ">>> Failed to get commit hash: %v\n", err)
|
||||
}
|
||||
|
||||
if runtime == "" {
|
||||
@@ -106,7 +118,7 @@ cmd = "%s"
|
||||
if _, err := os.Stat(filepath.Join(workDir, "package.json")); err == nil {
|
||||
configPath := filepath.Join(workDir, "nixpacks.toml")
|
||||
if err := os.WriteFile(configPath, []byte(nixpacksConfig), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write nixpacks.toml: %w", err)
|
||||
return "", "", fmt.Errorf("failed to write nixpacks.toml: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,10 +141,10 @@ cmd = "%s"
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
return imageName, nil
|
||||
return imageName, commitHash, nil
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
Password string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
gorm.Model
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
OwnerID uint `json:"owner_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Port int `json:"port"`
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
GitToken string `json:"-"`
|
||||
@@ -31,14 +40,17 @@ type Project struct {
|
||||
|
||||
type EnvVar struct {
|
||||
gorm.Model
|
||||
ProjectID uint `json:"project_id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type Deployment struct {
|
||||
gorm.Model
|
||||
ProjectID uint `json:"project_id"`
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Project Project `json:"project" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
Status string `json:"status"`
|
||||
Commit string `json:"commit"`
|
||||
@@ -51,6 +63,6 @@ type Database struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
OwnerID uint `json:"owner_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
SizeMB float64 `json:"size_mb"`
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ A modern, responsive dashboard to manage your applications, monitor deployments,
|
||||
|
||||
## Features
|
||||
- **Project List**: View all deployed applications.
|
||||
- **Real-time Logs**: Watch builds and runtime logs live.
|
||||
- **Deployment History**: Track past builds and status.
|
||||
- **Real-time Logs**: Watch builds and runtime logs live via WebSockets.
|
||||
- **Redeploy**: Trigger manual redeployments.
|
||||
- **Environment Variables**: Manage runtime configuration.
|
||||
- **Responsive Design**: Works on desktop and mobile.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
@@ -32,6 +32,8 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^17.0.1",
|
||||
"svelte-sonner": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,23 @@ export interface EnvVar {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Deployment {
|
||||
id: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
commit: string;
|
||||
logs: string;
|
||||
url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
ID: number;
|
||||
id: string;
|
||||
name: string;
|
||||
repo_url: string;
|
||||
port: number;
|
||||
deployments: any[];
|
||||
deployments: Deployment[];
|
||||
env_vars: EnvVar[];
|
||||
webhook_secret: string;
|
||||
git_token?: string;
|
||||
@@ -228,3 +239,33 @@ export async function createDatabase(name: string, type: string = "sqlite") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminUsers() {
|
||||
try {
|
||||
return await fetchWithAuth("/api/admin/users");
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(id: string) {
|
||||
try {
|
||||
await fetchWithAuth(`/api/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminStats() {
|
||||
try {
|
||||
return await fetchWithAuth("/api/admin/stats");
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface User {
|
||||
ID: number;
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
const storedUser = browser ? localStorage.getItem('user') : null;
|
||||
|
||||
@@ -42,15 +42,13 @@
|
||||
class="flex flex-wrap justify-center gap-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<a href="/" class="hover:text-foreground transition-colors">Home</a>
|
||||
<a href="/" class="hover:text-foreground transition-colors">Docs</a>
|
||||
<a href="/docs" class="hover:text-foreground transition-colors">Docs</a>
|
||||
<a
|
||||
href="https://github.com/SirBlobby/Clickploy"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="hover:text-foreground transition-colors">GitHub</a
|
||||
>
|
||||
<a href="/" class="hover:text-foreground transition-colors">Support</a>
|
||||
<a href="/" class="hover:text-foreground transition-colors">Legal</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +61,11 @@
|
||||
<span class={isNormal ? "text-blue-500" : "text-red-500"}>
|
||||
{isNormal ? "All systems normal." : status}
|
||||
</span>
|
||||
{#if version}
|
||||
<span class="text-muted-foreground ml-2">
|
||||
{version}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
Rocket,
|
||||
Network,
|
||||
Database,
|
||||
Shield,
|
||||
Book,
|
||||
} from "@lucide/svelte";
|
||||
import { page } from "$app/stores";
|
||||
import * as Sheet from "$lib/components/ui/sheet";
|
||||
@@ -35,48 +37,83 @@
|
||||
|
||||
{#if $user}
|
||||
<nav class="hidden md:flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" href="/" class={isActive("/")}>
|
||||
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/"
|
||||
class={`group ${isActive("/")}`}
|
||||
>
|
||||
<LayoutDashboard class="h-4 w-4" />
|
||||
<span
|
||||
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/" ? "max-w-xs opacity-100 ml-2" : ""}`}
|
||||
>
|
||||
Overview
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/deployments"
|
||||
class={isActive("/deployments")}
|
||||
class={`group ${isActive("/deployments")}`}
|
||||
>
|
||||
<Rocket class="mr-2 h-4 w-4" /> Deployments
|
||||
<Rocket class="h-4 w-4" />
|
||||
<span
|
||||
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/deployments" ? "max-w-xs opacity-100 ml-2" : ""}`}
|
||||
>
|
||||
Deployments
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/network"
|
||||
class={isActive("/network")}
|
||||
class={`group ${isActive("/network")}`}
|
||||
>
|
||||
<Network class="mr-2 h-4 w-4" /> Network
|
||||
<Network class="h-4 w-4" />
|
||||
<span
|
||||
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/network" ? "max-w-xs opacity-100 ml-2" : ""}`}
|
||||
>
|
||||
Network
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/activity"
|
||||
class={isActive("/activity")}
|
||||
class={`group ${isActive("/activity")}`}
|
||||
>
|
||||
<Activity class="mr-2 h-4 w-4" /> Activity
|
||||
<Activity class="h-4 w-4" />
|
||||
<span
|
||||
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/activity" ? "max-w-xs opacity-100 ml-2" : ""}`}
|
||||
>
|
||||
Activity
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/storage"
|
||||
class={isActive("/storage")}
|
||||
class={`group ${isActive("/storage")}`}
|
||||
>
|
||||
<Database class="mr-2 h-4 w-4" /> Storage
|
||||
<Database class="h-4 w-4" />
|
||||
<span
|
||||
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/storage" ? "max-w-xs opacity-100 ml-2" : ""}`}
|
||||
>
|
||||
Storage
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/settings"
|
||||
class={isActive("/settings")}
|
||||
href="/docs"
|
||||
class={`group ${isActive("/docs")}`}
|
||||
>
|
||||
<Settings class="mr-2 h-4 w-4" /> Settings
|
||||
<Book class="h-4 w-4" />
|
||||
<span
|
||||
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname.startsWith("/docs") ? "max-w-xs opacity-100 ml-2" : ""}`}
|
||||
>
|
||||
Docs
|
||||
</span>
|
||||
</Button>
|
||||
</nav>
|
||||
{/if}
|
||||
@@ -84,6 +121,26 @@
|
||||
|
||||
{#if $user}
|
||||
<div class="flex items-center gap-4">
|
||||
<nav class="hidden md:flex items-center gap-2 mr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/settings"
|
||||
class={isActive("/settings")}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
{#if $user.is_admin}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/admin"
|
||||
class={isActive("/admin")}
|
||||
>
|
||||
<Shield class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div
|
||||
class="h-8 w-8 rounded-full bg-linear-to-tr from-primary to-purple-500"
|
||||
@@ -147,6 +204,13 @@
|
||||
>
|
||||
<Database class="h-5 w-5" /> Storage
|
||||
</a>
|
||||
<a
|
||||
href="/docs"
|
||||
class="flex items-center gap-2 py-2 text-lg font-medium"
|
||||
onclick={() => (mobileOpen = false)}
|
||||
>
|
||||
<Book class="h-5 w-5" /> Docs
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex items-center gap-2 py-2 text-lg font-medium"
|
||||
@@ -154,6 +218,15 @@
|
||||
>
|
||||
<Settings class="h-5 w-5" /> Settings
|
||||
</a>
|
||||
{#if $user.is_admin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="flex items-center gap-2 py-2 text-lg font-medium"
|
||||
onclick={() => (mobileOpen = false)}
|
||||
>
|
||||
<Shield class="h-5 w-5" /> Admin
|
||||
</a>
|
||||
{/if}
|
||||
<div class="border-t my-2"></div>
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<div
|
||||
@@ -175,6 +248,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" size="sm" href="/docs">Docs</Button>
|
||||
<Button variant="ghost" size="sm" href="/login">Login</Button>
|
||||
<Button size="sm" href="/register">Get Started</Button>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ export {
|
||||
Root,
|
||||
Description,
|
||||
Title,
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle,
|
||||
|
||||
@@ -8,7 +8,6 @@ import Root, {
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
|
||||
@@ -14,7 +14,6 @@ export {
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
|
||||
@@ -6,7 +6,6 @@ export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
//
|
||||
Root as Collapsible,
|
||||
Content as CollapsibleContent,
|
||||
Trigger as CollapsibleTrigger,
|
||||
|
||||
@@ -20,7 +20,6 @@ export {
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
|
||||
@@ -2,6 +2,5 @@ import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,5 @@ import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,5 @@ import Root from "./progress.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress,
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ export {
|
||||
ScrollUpButton,
|
||||
GroupHeading,
|
||||
Portal,
|
||||
//
|
||||
Root as Select,
|
||||
Group as SelectGroup,
|
||||
Label as SelectLabel,
|
||||
|
||||
@@ -2,6 +2,5 @@ import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ export {
|
||||
Footer,
|
||||
Title,
|
||||
Description,
|
||||
//
|
||||
Root as Sheet,
|
||||
Close as SheetClose,
|
||||
Trigger as SheetTrigger,
|
||||
|
||||
@@ -16,7 +16,6 @@ export {
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
|
||||
30
frontend/src/routes/+error.svelte
Normal file
30
frontend/src/routes/+error.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { AlertTriangle, Home, ArrowLeft } from "@lucide/svelte";
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center min-h-[70vh] text-center px-4 animate-in fade-in zoom-in duration-300">
|
||||
<div class="bg-destructive/10 p-6 rounded-full mb-6">
|
||||
<AlertTriangle class="h-12 w-12 text-destructive" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl font-bold tracking-tight mb-2">
|
||||
{$page.status}
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-muted-foreground mb-8 max-w-md text-balance">
|
||||
{$page.error?.message || "Something went wrong."}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<Button variant="outline" onclick={() => history.back()}>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
<Button href="/">
|
||||
<Home class="mr-2 h-4 w-4" />
|
||||
Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
1
frontend/src/routes/+layout.ts
Normal file
1
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -22,6 +22,7 @@
|
||||
Settings,
|
||||
ChevronsUpDown,
|
||||
ExternalLink,
|
||||
Upload,
|
||||
} from "@lucide/svelte";
|
||||
import * as Collapsible from "$lib/components/ui/collapsible";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
@@ -59,6 +60,48 @@
|
||||
envVars = envVars.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
async function handleEnvUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const file = input.files[0];
|
||||
const text = await file.text();
|
||||
|
||||
const newVars: { key: string; value: string }[] = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
let value = trimmed.slice(eqIdx + 1).trim();
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key) {
|
||||
newVars.push({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
if (newVars.length > 0) {
|
||||
// If the first item is empty, remove it
|
||||
if (envVars.length === 1 && !envVars[0].key && !envVars[0].value) {
|
||||
envVars = newVars;
|
||||
} else {
|
||||
envVars = [...envVars, ...newVars];
|
||||
}
|
||||
}
|
||||
|
||||
input.value = ''; // Reset input
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!repo || !name) return;
|
||||
deploying = true;
|
||||
@@ -108,21 +151,99 @@
|
||||
<div class="container mx-auto py-10 px-4">
|
||||
{#if !$user}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center space-y-10 py-20 text-center"
|
||||
class="flex flex-col items-center justify-center min-h-[60vh] text-center space-y-8 animate-in fade-in zoom-in duration-500"
|
||||
>
|
||||
Deploy with <span
|
||||
class="bg-linear-to-r from-blue-400 to-purple-600 bg-clip-text text-transparent"
|
||||
>One Click</span
|
||||
>.
|
||||
<p class="max-w-[600px] text-muted-foreground text-xl">
|
||||
The simplified PaaS for your personal projects. Push, build, and scale
|
||||
without the complexity.
|
||||
<div
|
||||
class="bg-primary/10 p-4 rounded-full mb-4 ring-1 ring-primary/20 shadow-[0_0_30px_-10px_rgba(255,255,255,0.3)]"
|
||||
>
|
||||
<Terminal class="w-12 h-12 text-primary" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 max-w-2xl">
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold tracking-tight">
|
||||
Deploy with Clickploy
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground leading-relaxed">
|
||||
Self-hosted PaaS made simple. Push your code, we handle the rest. No
|
||||
complex configs, just pure deployment power.
|
||||
</p>
|
||||
<div class="flex gap-4">
|
||||
<Button href="/login" size="lg">Get Started</Button>
|
||||
<Button href="https://github.com/clickploy" variant="outline" size="lg">
|
||||
<Github class="mr-2 h-4 w-4" /> GitHub
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 w-full sm:w-auto pt-4">
|
||||
<Button
|
||||
href="/login"
|
||||
size="lg"
|
||||
class="min-w-[160px] text-lg h-12 shadow-lg hover:shadow-primary/25 transition-all"
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
<Button
|
||||
href="https://github.com/SirBlobby/Clickploy"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="min-w-[160px] text-lg h-12"
|
||||
>
|
||||
<Github class="mr-2 h-5 w-5" /> GitHub
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pt-12 w-full max-w-5xl text-left"
|
||||
>
|
||||
<Card class="bg-card/50 border-muted">
|
||||
<CardHeader class="pb-2">
|
||||
<Activity class="w-8 h-8 text-primary mb-2" />
|
||||
<CardTitle class="text-lg">Zero Config</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground">
|
||||
Deploy from any Git repo without writing Dockerfiles or YAML.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-muted">
|
||||
<CardHeader class="pb-2">
|
||||
<Terminal class="w-8 h-8 text-primary mb-2" />
|
||||
<CardTitle class="text-lg">Docker Native</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground">
|
||||
Every app runs in its own isolated container for security.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-muted">
|
||||
<CardHeader class="pb-2">
|
||||
<ExternalLink class="w-8 h-8 text-primary mb-2" />
|
||||
<CardTitle class="text-lg">Auto Ports</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground">
|
||||
We automatically assign and manage ports for your services.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-muted">
|
||||
<CardHeader class="pb-2">
|
||||
<Settings class="w-8 h-8 text-primary mb-2" />
|
||||
<CardTitle class="text-lg">Full Control</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground">
|
||||
Self-hosted means you own your data, logs, and infrastructure.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-muted">
|
||||
<CardHeader class="pb-2">
|
||||
<Terminal class="w-8 h-8 text-primary mb-2" />
|
||||
<CardTitle class="text-lg">CLI Integration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground">
|
||||
Manage your deployments from the terminal. (Coming Soon)
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-muted">
|
||||
<CardHeader class="pb-2">
|
||||
<Github class="w-8 h-8 text-primary mb-2" />
|
||||
<CardTitle class="text-lg">Git Webhooks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground">
|
||||
Automatically deploy when you push changes to your repository.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -165,14 +286,14 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
readonly
|
||||
value={`http://localhost:8080/webhooks/trigger?project_id=${createdProject.ID}`}
|
||||
value={`http://localhost:8080/webhooks/trigger?project_id=${createdProject.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`http://localhost:8080/webhooks/trigger?project_id=${createdProject.ID}`,
|
||||
`http://localhost:8080/webhooks/trigger?project_id=${createdProject.id}`,
|
||||
)}
|
||||
>
|
||||
Copy
|
||||
@@ -341,6 +462,24 @@
|
||||
>
|
||||
<Plus class="mr-2 h-4 w-4" /> Add Variable
|
||||
</Button>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".env,text/plain"
|
||||
class="hidden"
|
||||
id="env-upload"
|
||||
onchange={handleEnvUpload}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
onclick={() => document.getElementById('env-upload')?.click()}
|
||||
>
|
||||
<Upload class="mr-2 h-4 w-4" /> Upload .env File
|
||||
</Button>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
@@ -391,16 +530,16 @@
|
||||
<Card
|
||||
class="group hover:shadow-lg transition-all duration-300 border-muted/60 hover:border-primary/50 cursor-pointer overflow-hidden relative"
|
||||
>
|
||||
<a href={`/projects/${project.ID}`} class="block h-full">
|
||||
<a href={`/projects/${project.id}`} class="block h-full">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-1">
|
||||
<CardTitle class="text-xl flex items-center gap-2">
|
||||
{project.name}
|
||||
</CardTitle>
|
||||
<CardDescription class="flex items-center gap-1">
|
||||
<CardDescription class="flex items-center gap-1" >
|
||||
<Github class="h-3 w-3" />
|
||||
{new URL(project.repo_url).pathname.slice(1)}
|
||||
<a href={project.repo_url} target="_blank">{new URL(project.repo_url).pathname.slice(1)}</a>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<span
|
||||
@@ -464,7 +603,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
|
||||
href={`/projects/${project.ID}`}
|
||||
href={`/projects/${project.id}`}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
|
||||
@@ -60,10 +60,10 @@
|
||||
<div
|
||||
class="grid grid-cols-12 gap-4 px-4 py-3 border-b text-xs font-medium text-muted-foreground bg-muted/40 uppercase tracking-wider"
|
||||
>
|
||||
<div class="col-span-3">Project</div>
|
||||
<div class="col-span-4">Commit</div>
|
||||
<div class="col-span-2">Status</div>
|
||||
<div class="col-span-3 text-right">Time</div>
|
||||
<div class="col-span-6 md:col-span-3">Project</div>
|
||||
<div class="hidden md:block col-span-4">Commit</div>
|
||||
<div class="col-span-4 md:col-span-2">Status</div>
|
||||
<div class="col-span-2 md:col-span-3 text-right">Time</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-border/40">
|
||||
@@ -71,7 +71,7 @@
|
||||
<div
|
||||
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-colors text-sm group"
|
||||
>
|
||||
<div class="col-span-3 flex items-center gap-2 overflow-hidden">
|
||||
<div class="col-span-6 md:col-span-3 flex items-center gap-2 overflow-hidden">
|
||||
<div
|
||||
class="h-8 w-8 rounded bg-primary/10 flex items-center justify-center shrink-0 text-primary uppercase font-bold text-xs ring-1 ring-inset ring-primary/20"
|
||||
>
|
||||
@@ -86,7 +86,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-4 flex items-center gap-2 font-mono text-xs text-muted-foreground"
|
||||
class="hidden md:flex col-span-4 items-center gap-2 font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
<GitCommit class="h-3.5 w-3.5 shrink-0" />
|
||||
<span
|
||||
@@ -103,7 +103,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<div class="col-span-4 md:col-span-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border
|
||||
{activity.status === 'live'
|
||||
@@ -122,9 +122,9 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-3 flex items-center justify-end gap-3 text-right"
|
||||
class="col-span-2 md:col-span-3 flex items-center justify-end gap-3 text-right"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
<span class="hidden md:inline text-xs text-muted-foreground">
|
||||
{new Date(activity.CreatedAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
||||
158
frontend/src/routes/admin/+page.svelte
Normal file
158
frontend/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { getAdminUsers, deleteAdminUser, getAdminStats } from "$lib/api";
|
||||
import { user, type User } from "$lib/auth";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "$lib/components/ui/table";
|
||||
import { Loader2, Trash2, Shield, Users, Box, Layers } from "@lucide/svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
let loading = true;
|
||||
let users: any[] = [];
|
||||
let stats: any = null;
|
||||
|
||||
onMount(async () => {
|
||||
if (!$user || !$user.is_admin) {
|
||||
toast.error("Unauthorized");
|
||||
goto("/");
|
||||
return;
|
||||
}
|
||||
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
const [u, s] = await Promise.all([getAdminUsers(), getAdminStats()]);
|
||||
users = u || [];
|
||||
stats = s;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function handleDeleteUser(id: string) {
|
||||
if (!confirm("Are you sure you want to delete this user?")) return;
|
||||
const success = await deleteAdminUser(id);
|
||||
if (success) {
|
||||
toast.success("User deleted");
|
||||
users = users.filter((u) => u.id !== id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-10 px-4">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold tracking-tight">Admin Dashboard</h2>
|
||||
<p class="text-muted-foreground">System overview and user management.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center p-20">
|
||||
<Loader2 class="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stats Cards -->
|
||||
{#if stats}
|
||||
<div class="grid gap-4 md:grid-cols-3 mb-8">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle class="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{stats.users}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle class="text-sm font-medium">Total Projects</CardTitle>
|
||||
<Box class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{stats.projects}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle class="text-sm font-medium">Total Deployments</CardTitle>
|
||||
<Layers class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{stats.deployments}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Users Table -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CardDescription>Manage registered users.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead class="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each users as u}
|
||||
<TableRow>
|
||||
<TableCell>{u.id}</TableCell>
|
||||
<TableCell class="font-medium">{u.name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>{u.projects?.length || 0}</TableCell>
|
||||
<TableCell>
|
||||
{#if u.is_admin}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-transparent bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary"
|
||||
>
|
||||
<Shield class="mr-1 h-3 w-3" /> Admin
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">User</span>
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{#if !u.is_admin}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive hover:text-destructive/90"
|
||||
onclick={() => handleDeleteUser(u.id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -67,9 +67,9 @@
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Commit</TableHead>
|
||||
<TableHead class="hidden md:table-cell">Commit</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead class="hidden md:table-cell">Created</TableHead>
|
||||
<TableHead class="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -79,7 +79,7 @@
|
||||
<TableCell class="font-medium">
|
||||
{#if deploy.project}
|
||||
<a
|
||||
href={`/projects/${deploy.project.ID}`}
|
||||
href={`/projects/${deploy.project.id}`}
|
||||
class="hover:underline flex items-center gap-2"
|
||||
>
|
||||
{deploy.project.name}
|
||||
@@ -88,7 +88,7 @@
|
||||
<span class="text-muted-foreground">Deleted Project</span>
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell class="hidden md:table-cell">
|
||||
<div class="flex items-center gap-2">
|
||||
<GitCommit class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="font-mono text-sm"
|
||||
@@ -108,7 +108,7 @@
|
||||
{deploy.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
<TableCell class="hidden md:table-cell text-muted-foreground">
|
||||
{new Date(deploy.CreatedAt).toLocaleDateString()}
|
||||
{new Date(deploy.CreatedAt).toLocaleTimeString()}
|
||||
</TableCell>
|
||||
|
||||
48
frontend/src/routes/docs/+layout.svelte
Normal file
48
frontend/src/routes/docs/+layout.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Book } from "@lucide/svelte";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
function isActive(path: string) {
|
||||
return $page.url.pathname === path ? "bg-secondary" : "hover:bg-accent/50";
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: "/docs", label: "Introduction" },
|
||||
{ href: "/docs/features", label: "Features" },
|
||||
{ href: "/docs/architecture", label: "Architecture" },
|
||||
{ href: "/docs/api", label: "API Reference" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-10 px-4">
|
||||
<div class="flex flex-col md:flex-row gap-10">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="hidden md:block w-64 shrink-0 space-y-8">
|
||||
<div class="sticky top-20">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<Book class="h-6 w-6 text-primary" />
|
||||
<h2 class="text-xl font-bold tracking-tight">Documentation</h2>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
{#each navItems as item}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full justify-start {isActive(item.href)}"
|
||||
href={item.href}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 space-y-12 max-w-4xl min-h-[50vh]">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
23
frontend/src/routes/docs/+page.svelte
Normal file
23
frontend/src/routes/docs/+page.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Introduction
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground">
|
||||
A minimal, self-hosted Platform as a Service (PaaS) for building and
|
||||
deploying applications quickly.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<p>
|
||||
Clickploy is designed to be a simple, powerful alternative to complex
|
||||
container orchestration platforms. It leverages Docker and Nixpacks to turn
|
||||
your Git repositories into running applications with zero configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
132
frontend/src/routes/docs/api/+page.svelte
Normal file
132
frontend/src/routes/docs/api/+page.svelte
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "$lib/components/ui/table";
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
API Reference
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground">
|
||||
The backend exposes a RESTful API for automation and integration. All
|
||||
authenticated endpoints require a <code>Bearer</code> token.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Project Endpoints -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xl font-semibold">Projects</h3>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[100px]">Method</TableHead>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><Badge>GET</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/projects</TableCell>
|
||||
<TableCell>List all projects for the current user.</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><Badge>POST</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/projects</TableCell>
|
||||
<TableCell>Create and deploy a new project.</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/projects/:id</TableCell>
|
||||
<TableCell>Get details of a specific project.</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><Badge>POST</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/projects/:id/redeploy</TableCell>
|
||||
<TableCell>Trigger a manual redeployment.</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><Badge variant="outline">PUT</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/projects/:id/env</TableCell>
|
||||
<TableCell>Update environment variables.</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Activity & Stats -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xl font-semibold">Activity & Data</h3>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[100px]">Method</TableHead>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/activity</TableCell>
|
||||
<TableCell>Get recent deployment activity.</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- System Endpoints -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xl font-semibold">System & Admin</h3>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[100px]">Method</TableHead>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/system/status</TableCell>
|
||||
<TableCell>Check backend health and version.</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/admin/stats</TableCell>
|
||||
<TableCell>Get global system statistics (Admin only).</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
|
||||
<TableCell class="font-mono">/api/admin/users</TableCell>
|
||||
<TableCell>List all users (Admin only).</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
44
frontend/src/routes/docs/architecture/+page.svelte
Normal file
44
frontend/src/routes/docs/architecture/+page.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Architecture
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground">
|
||||
How Clickploy components interact.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardContent class="pt-6 space-y-4">
|
||||
<p>
|
||||
Clickploy operates as a control plane for your deployments. Here's how the
|
||||
components interact:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong class="text-foreground">Backend (Go):</strong> Handles API
|
||||
requests, manages the SQLite database, and orchestrates Docker
|
||||
containers.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-foreground">Frontend (SvelteKit):</strong> Provides a
|
||||
reactive web interface for managing projects and viewing logs.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-foreground">Builder (Nixpacks):</strong> Analyzes
|
||||
source code and generates OCI-compliant images.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-foreground">Reverse Proxy:</strong> (Optional)
|
||||
Typically runs behind Nginx or Caddy for SSL termination.
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
158
frontend/src/routes/docs/features/+page.svelte
Normal file
158
frontend/src/routes/docs/features/+page.svelte
Normal file
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import {
|
||||
Globe,
|
||||
Server,
|
||||
Terminal,
|
||||
Database,
|
||||
Settings,
|
||||
Shield,
|
||||
HardDrive,
|
||||
} from "@lucide/svelte";
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Features
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground">
|
||||
Explore the core capabilities of Clickploy and learn how to use them.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<!-- Deployment & Build -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Deployment & Build</h2>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Globe class="h-5 w-5 text-primary" />
|
||||
Auto-Build System
|
||||
</CardTitle>
|
||||
<CardDescription>Zero-config builds using Nixpacks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<p>
|
||||
Clickploy automatically detects your application's language and framework (Node.js, Python, Go, Rust, etc.) and builds a container image without needing a Dockerfile.
|
||||
</p>
|
||||
<div class="bg-muted p-3 rounded-md text-sm">
|
||||
<strong>How to use:</strong> Simply paste your Git repository URL when creating a new project. Clickploy handles the rest.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Server class="h-5 w-5 text-primary" />
|
||||
Isolated Containers
|
||||
</CardTitle>
|
||||
<CardDescription>Docker-based runtime</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<p>
|
||||
Every application runs in its own isolated Docker container. This ensures consistent performance, security, and prevents dependency conflicts between projects.
|
||||
</p>
|
||||
<div class="bg-muted p-3 rounded-md text-sm">
|
||||
<strong>How it works:</strong> The system assigns a unique port and manages the container lifecycle (start, stop, restart) automatically.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Management & Ops -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Management & Operations</h2>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Terminal class="h-5 w-5 text-primary" />
|
||||
Real-time Observability
|
||||
</CardTitle>
|
||||
<CardDescription>Live logs and deployment history</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<p>
|
||||
Watch your build process and application logs stream in real-time via WebSockets. Access historical logs for any past deployment to debug issues.
|
||||
</p>
|
||||
<div class="bg-muted p-3 rounded-md text-sm">
|
||||
<strong>How to use:</strong> Open a project dashboard. The terminal window shows live logs. Click "History" to view past deployments.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Settings class="h-5 w-5 text-primary" />
|
||||
Environment Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>Secrets and Variables</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<p>
|
||||
Manage environment variables (API keys, database URLs) securely. Variables are injected into the container at runtime.
|
||||
</p>
|
||||
<div class="bg-muted p-3 rounded-md text-sm">
|
||||
<strong>How to use:</strong> Go to the <strong>Settings</strong> tab in your project dashboard to add or update variables.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Storage & Admin -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Storage & Administration</h2>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<HardDrive class="h-5 w-5 text-primary" />
|
||||
Managed Storage
|
||||
</CardTitle>
|
||||
<CardDescription>Integrated Databases</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<p>
|
||||
Provision lightweight SQLite databases instantly for your applications. Perfect for prototypes and small-to-medium apps.
|
||||
</p>
|
||||
<div class="bg-muted p-3 rounded-md text-sm">
|
||||
<strong>How to use:</strong> Navigate to the <strong>Storage</strong> page to create and manage databases.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Shield class="h-5 w-5 text-primary" />
|
||||
Admin Control
|
||||
</CardTitle>
|
||||
<CardDescription>User Management & Stats</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<p>
|
||||
Administrators have full visibility into system performance, user registration, and global deployment statistics.
|
||||
</p>
|
||||
<div class="bg-muted p-3 rounded-md text-sm">
|
||||
<strong>How to use:</strong> Admin users can access the <strong>Admin</strong> dashboard from the sidebar to manage users and view system health.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -88,7 +88,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Server class="h-4 w-4 text-muted-foreground" />
|
||||
<a
|
||||
href={`/projects/${project.ID}`}
|
||||
href={`/projects/${project.id}`}
|
||||
class="hover:underline"
|
||||
>
|
||||
{project.name}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
let status = $derived(latestDeployment?.status || "unknown");
|
||||
|
||||
let activeDeploymentLogs = $state("");
|
||||
let activeDeploymentId = $state<number | null>(null);
|
||||
let activeDeploymentId = $state<string | null>(null);
|
||||
let ws = $state<WebSocket | null>(null);
|
||||
let logContentRef = $state<HTMLDivElement | null>(null);
|
||||
let copied = $state(false);
|
||||
@@ -63,7 +63,7 @@
|
||||
async function handleRedeploy() {
|
||||
if (!project) return;
|
||||
toast.info("Starting redeployment...");
|
||||
const success = await redeployProject(project.ID.toString());
|
||||
const success = await redeployProject(project.id.toString());
|
||||
if (success) {
|
||||
toast.success("Redeployment started!");
|
||||
setTimeout(loadProject, 1000);
|
||||
@@ -71,16 +71,16 @@
|
||||
}
|
||||
|
||||
function selectDeployment(deployment: any) {
|
||||
if (activeDeploymentId === deployment.ID) return;
|
||||
if (activeDeploymentId === deployment.id) return;
|
||||
|
||||
activeDeploymentId = deployment.ID;
|
||||
activeDeploymentId = deployment.id;
|
||||
activeDeploymentLogs = deployment.logs || "";
|
||||
userScrolled = false;
|
||||
autoScroll = true;
|
||||
scrollToBottom(true);
|
||||
|
||||
if (deployment.status === "building") {
|
||||
connectWebSocket(deployment.ID);
|
||||
connectWebSocket(deployment.id);
|
||||
} else {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
@@ -89,7 +89,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket(deploymentId: number) {
|
||||
function connectWebSocket(deploymentId: string) {
|
||||
if (ws) ws.close();
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(
|
||||
@@ -143,7 +143,7 @@
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if project}
|
||||
<div class="space-y-4 h-[calc(100vh-140px)] flex flex-col overflow-hidden">
|
||||
<div class="space-y-4 lg:h-[calc(100vh-140px)] flex flex-col lg:overflow-hidden">
|
||||
<div class="shrink-0 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -193,7 +193,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-4 gap-px bg-border/40 rounded-lg overflow-hidden border"
|
||||
class="grid grid-cols-2 lg:grid-cols-4 gap-px bg-border/40 rounded-lg overflow-hidden border"
|
||||
>
|
||||
<div
|
||||
class="bg-card p-3 flex flex-col justify-center items-center relative overflow-hidden group"
|
||||
@@ -260,7 +260,7 @@
|
||||
|
||||
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-4 min-h-0">
|
||||
<Card
|
||||
class="flex flex-col min-h-0 bg-transparent shadow-none border-0 lg:col-span-1"
|
||||
class="flex flex-col min-h-0 h-[300px] lg:h-auto bg-transparent shadow-none border-0 lg:col-span-1"
|
||||
>
|
||||
<div class="flex items-center justify-between px-1 pb-2">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground">History</h3>
|
||||
@@ -270,25 +270,29 @@
|
||||
{#each project.deployments as deployment}
|
||||
<button
|
||||
class="w-full flex items-center justify-between p-2.5 rounded-md border text-left transition-all text-xs
|
||||
{activeDeploymentId === deployment.ID
|
||||
{activeDeploymentId === deployment.id
|
||||
? 'bg-primary/5 border-primary/20 shadow-sm'
|
||||
: 'bg-card hover:bg-muted/50 border-input'}"
|
||||
onclick={() => selectDeployment(deployment)}
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">#{deployment.ID}</span>
|
||||
<span class="font-semibold text-xs">#{deployment.id}</span>
|
||||
<span
|
||||
class="font-mono text-[10px] text-muted-foreground bg-muted px-1 rounded flex items-center gap-1"
|
||||
>
|
||||
<GitCommit class="h-2 w-2" />
|
||||
{deployment.commit
|
||||
? deployment.commit.substring(0, 7)
|
||||
: "HEAD"}
|
||||
{deployment.commit === "HEAD"
|
||||
? "HEAD"
|
||||
: deployment.commit === "MANUAL"
|
||||
? "Manual"
|
||||
: deployment.commit === "WEBHOOK"
|
||||
? "Webhook"
|
||||
: deployment.commit.substring(0, 7)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted-foreground truncate">
|
||||
{new Date(deployment.CreatedAt).toLocaleString(undefined, {
|
||||
{new Date(deployment.created_at).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
@@ -322,14 +326,15 @@
|
||||
</Card>
|
||||
|
||||
<div
|
||||
class="lg:col-span-3 flex flex-col min-h-0 rounded-lg border bg-zinc-950 shadow-sm overflow-hidden border-border/40"
|
||||
class="lg:col-span-3 flex flex-col min-h-0 h-[500px] lg:h-auto rounded-lg border bg-card shadow-sm overflow-hidden border-border/40"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between px-3 py-2 bg-zinc-900/50 border-b border-white/5"
|
||||
class="flex shrink-0 items-center justify-between px-3 py-2 bg-muted/50 border-b border-border"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Terminal class="h-3.5 w-3.5 text-zinc-400" />
|
||||
<span class="text-xs font-mono text-zinc-300">
|
||||
<Terminal class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
<span class="text-xs font-mono text-muted-foreground">
|
||||
{#if activeDeploymentId}
|
||||
build-log-{activeDeploymentId}.log
|
||||
{:else}
|
||||
@@ -365,7 +370,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-zinc-400 hover:text-white"
|
||||
class="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onclick={copyLogs}
|
||||
title="Copy Logs"
|
||||
>
|
||||
@@ -381,14 +386,14 @@
|
||||
<div
|
||||
bind:this={logContentRef}
|
||||
onscroll={handleScroll}
|
||||
class="flex-1 overflow-auto p-3 font-mono text-[11px] leading-relaxed text-gray-200 scrollbar-thin scrollbar-thumb-zinc-800 scrollbar-track-transparent selection:bg-white/20"
|
||||
class="flex-1 overflow-auto p-4 font-mono text-[11px] leading-relaxed text-foreground scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent selection:bg-primary/20 bg-card"
|
||||
>
|
||||
{#if activeDeploymentLogs}
|
||||
<pre
|
||||
class="whitespace-pre-wrap break-all">{activeDeploymentLogs}</pre>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full items-center justify-center text-zinc-600 italic text-xs"
|
||||
class="flex h-full items-center justify-center text-muted-foreground italic text-xs"
|
||||
>
|
||||
<p>Select a deployment to view logs</p>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
(d) =>
|
||||
d.commit.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
d.status.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
d.ID.toString().includes(searchTerm),
|
||||
d.id.toString().includes(searchTerm),
|
||||
) || [],
|
||||
);
|
||||
|
||||
@@ -90,26 +90,26 @@
|
||||
<CardContent class="p-0">
|
||||
{#if project.deployments?.length}
|
||||
<div
|
||||
class="grid grid-cols-12 gap-4 px-4 py-3 border-b text-xs font-medium text-muted-foreground bg-muted/40 uppercase tracking-wider"
|
||||
class="grid grid-cols-12 gap-4 px-4 py-3 border-b text-xs font-medium text-muted-foreground bg-muted/40 uppercase tracking-wider text-center"
|
||||
>
|
||||
<div class="col-span-1">ID</div>
|
||||
<div class="col-span-2">Status</div>
|
||||
<div class="col-span-5">Commit</div>
|
||||
<div class="col-span-3">Date</div>
|
||||
<div class="col-span-1 text-right">Actions</div>
|
||||
<div class="col-span-4 md:col-span-1">ID</div>
|
||||
<div class="col-span-4 md:col-span-2">Status</div>
|
||||
<div class="hidden md:block col-span-5">Commit</div>
|
||||
<div class="hidden md:block col-span-3">Date</div>
|
||||
<div class="col-span-4 md:col-span-1">Actions</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-border/40">
|
||||
{#each filteredDeployments as deployment}
|
||||
{@const StatusIcon = getStatusIcon(deployment.status)}
|
||||
<div
|
||||
class="grid grid-cols-12 gap-4 px-4 py-2.5 items-center hover:bg-muted/30 transition-colors text-sm group"
|
||||
class="grid grid-cols-12 gap-4 px-4 py-2.5 items-center hover:bg-muted/30 transition-colors text-sm group text-center"
|
||||
>
|
||||
<div class="col-span-1 font-mono text-xs text-muted-foreground">
|
||||
#{deployment.ID}
|
||||
<div class="col-span-4 md:col-span-1 font-mono text-xs text-muted-foreground">
|
||||
#{deployment.id}
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex items-center gap-2">
|
||||
<div class="col-span-4 md:col-span-2 flex items-center justify-center gap-2">
|
||||
<StatusIcon
|
||||
class="h-4 w-4 {getStatusColor(
|
||||
deployment.status,
|
||||
@@ -125,15 +125,19 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-5 flex items-center gap-2 font-mono text-xs"
|
||||
class="hidden md:flex col-span-5 items-center justify-center gap-2 font-mono text-xs"
|
||||
>
|
||||
<GitCommit class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span
|
||||
class="bg-muted px-1.5 py-0.5 rounded border border-border/50 text-foreground/80"
|
||||
class="bg-muted px-2 py-0.5 rounded-md border border-border/50 text-foreground/80 font-mono"
|
||||
>
|
||||
{deployment.commit
|
||||
? deployment.commit.substring(0, 7)
|
||||
: "HEAD"}
|
||||
{deployment.commit === "HEAD"
|
||||
? "HEAD"
|
||||
: deployment.commit === "MANUAL"
|
||||
? "MANUAL"
|
||||
: deployment.commit === "WEBHOOK"
|
||||
? "WEBHOOK"
|
||||
: deployment.commit.substring(0, 7)}
|
||||
</span>
|
||||
<span
|
||||
class="text-muted-foreground truncate hidden md:inline-block max-w-[200px]"
|
||||
@@ -146,12 +150,12 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 text-xs text-muted-foreground">
|
||||
{new Date(deployment.CreatedAt).toLocaleString()}
|
||||
<div class="hidden md:block col-span-3 text-xs text-muted-foreground">
|
||||
{new Date(deployment.created_at).toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-1 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="col-span-4 md:col-span-1 flex items-center justify-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{#if deployment.status === "live"}
|
||||
<Button
|
||||
@@ -169,7 +173,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
href={`/projects/${project.ID}?deployment=${deployment.ID}`}
|
||||
href={`/projects/${project.id}?deployment=${deployment.id}`}
|
||||
title="View Logs"
|
||||
>
|
||||
<Terminal class="h-3.5 w-3.5" />
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
EyeOff,
|
||||
Plus,
|
||||
Copy,
|
||||
Upload,
|
||||
} from "@lucide/svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
@@ -90,12 +91,12 @@
|
||||
}
|
||||
|
||||
loading = true;
|
||||
const success = await updateProjectEnv(project.ID.toString(), envMap);
|
||||
const success = await updateProjectEnv(project.id.toString(), envMap);
|
||||
loading = false;
|
||||
|
||||
if (success) {
|
||||
toast.success("Environment variables updated successfully");
|
||||
const res = await getProject(project.ID.toString());
|
||||
const res = await getProject(project.id.toString());
|
||||
if (res) {
|
||||
project = res;
|
||||
initEnvVars();
|
||||
@@ -107,10 +108,46 @@
|
||||
|
||||
function copyWebhook() {
|
||||
if (!project) return;
|
||||
const displayUrl = `http://localhost:8080/webhooks/trigger?project_id=${project.ID}`;
|
||||
const displayUrl = `http://localhost:8080/projects/${project.id}/webhook/${project.webhook_secret}`;
|
||||
navigator.clipboard.writeText(displayUrl);
|
||||
toast.success("Webhook URL copied");
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const text = await file.text();
|
||||
const lines = text.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const [key, ...parts] = trimmed.split("=");
|
||||
if (key) {
|
||||
const value = parts.join("=");
|
||||
const existingIndex = tempEnvVars.findIndex(
|
||||
(e) => e.key === key.trim(),
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
tempEnvVars[existingIndex].value = value.replace(
|
||||
/^["'](.*)["']$/,
|
||||
"$1",
|
||||
); // remove quotes
|
||||
} else {
|
||||
tempEnvVars = [
|
||||
...tempEnvVars,
|
||||
{ key: key.trim(), value: value.replace(/^["'](.*)["']$/, "$1") },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
isDirty = true;
|
||||
target.value = "";
|
||||
toast.success("Parsed .env file successfully");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading && !project}
|
||||
@@ -166,14 +203,32 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full h-11 border-dashed border-border/60 hover:bg-muted/50"
|
||||
class="flex-1 h-11 border-dashed border-border/60 hover:bg-muted/50"
|
||||
onclick={addEnvVar}
|
||||
>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Variable
|
||||
</Button>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".env"
|
||||
class="hidden"
|
||||
id="env-upload"
|
||||
onchange={handleFileUpload}
|
||||
/>
|
||||
<Label
|
||||
for="env-upload"
|
||||
class="flex items-center justify-center px-4 h-11 rounded-md border border-dashed border-border/60 hover:bg-muted/50 cursor-pointer bg-background"
|
||||
>
|
||||
<Upload class="h-4 w-4 mr-2" />
|
||||
Upload .env
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="border-t px-4 flex justify-end">
|
||||
<Button onclick={saveEnvVars} disabled={!isDirty || loading} size="sm">
|
||||
@@ -199,7 +254,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
readonly
|
||||
value={`http://localhost:8080/webhooks/trigger?project_id=${project.ID}`}
|
||||
value={`http://localhost:8080/projects/${project.id}/webhook/${project.webhook_secret}`}
|
||||
class="bg-muted font-mono text-xs"
|
||||
/>
|
||||
<Button variant="outline" size="icon" onclick={copyWebhook}>
|
||||
@@ -207,15 +262,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm">Webhook Secret</Label>
|
||||
<Input
|
||||
type="password"
|
||||
readonly
|
||||
value={project.webhook_secret}
|
||||
class="bg-muted font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -88,8 +88,11 @@
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>New Password</Label>
|
||||
type="password" bind:value={newPassword}
|
||||
required minlength={6}
|
||||
<Input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
required
|
||||
minlength={6}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary" disabled={loading}
|
||||
|
||||
Reference in New Issue
Block a user