Admin Dashboard, Webdocs, LICENSE, webhook, and ID's update.

This commit is contained in:
2026-02-04 03:05:12 +00:00
parent 890e52af8c
commit 1d0ccca7d1
51 changed files with 1290 additions and 229 deletions

21
LICENSE Normal file
View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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

View File

@@ -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=

View 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,
})
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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"})

View 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 {

View File

@@ -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))

View File

@@ -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,

View File

@@ -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
}

View File

@@ -1,49 +1,61 @@
package models
import (
"time"
"gorm.io/gorm"
)
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex;not null" json:"email"`
Password string `json:"-"`
Name string `json:"name"`
Avatar string `json:"avatar"`
Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"`
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
Name string `gorm:"uniqueIndex" json:"name"`
RepoURL string `json:"repo_url"`
OwnerID uint `json:"owner_id"`
Port int `json:"port"`
WebhookSecret string `json:"webhook_secret"`
GitToken string `json:"-"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
InstallCommand string `json:"install_command"`
Runtime string `json:"runtime"`
Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"`
EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"`
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 string `json:"owner_id"`
Port int `json:"port"`
WebhookSecret string `json:"webhook_secret"`
GitToken string `json:"-"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
InstallCommand string `json:"install_command"`
Runtime string `json:"runtime"`
Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"`
EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"`
}
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"`
Project Project `json:"project" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
Status string `json:"status"`
Commit string `json:"commit"`
Logs string `json:"logs"`
URL string `json:"url"`
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"`
Logs string `json:"logs"`
URL string `json:"url"`
}
type Database struct {
@@ -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"`
}

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -7,7 +7,6 @@ export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,

View File

@@ -8,7 +8,6 @@ import Root, {
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,

View File

@@ -14,7 +14,6 @@ export {
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,

View File

@@ -6,7 +6,6 @@ export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,

View File

@@ -20,7 +20,6 @@ export {
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,

View File

@@ -2,6 +2,5 @@ import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -2,6 +2,5 @@ import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -2,6 +2,5 @@ import Root from "./progress.svelte";
export {
Root,
//
Root as Progress,
};

View File

@@ -22,7 +22,6 @@ export {
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,

View File

@@ -2,6 +2,5 @@ import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -20,7 +20,6 @@ export {
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,

View File

@@ -16,7 +16,6 @@ export {
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,

View 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>

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -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.
</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
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>
<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>

View File

@@ -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",

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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]"
@@ -142,16 +146,16 @@
? "Manual Redeploy"
: deployment.commit === "WEBHOOK"
? "Webhook Trigger"
: "Git Push"}
: "Git Push"}
</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" />

View File

@@ -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}
<Button
variant="outline"
class="w-full 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="flex gap-2">
<Button
variant="outline"
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>

View File

@@ -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}