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.
|
- **Docker-based**: Each deployment runs in an isolated Docker container.
|
||||||
- **Real-time Logs**: View build and runtime logs streamed directly to the dashboard.
|
- **Real-time Logs**: View build and runtime logs streamed directly to the dashboard.
|
||||||
- **Port Management**: Automatically assigns and manages ports for your applications.
|
- **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.
|
- **Zero Configuration**: Just provide a repo URL, and Clickploy handles the rest.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
@@ -15,7 +18,17 @@ A minimal, self-hosted Platform as a Service (PaaS) for building and deploying a
|
|||||||
- **Build Engine**: Nixpacks
|
- **Build Engine**: Nixpacks
|
||||||
- **Database**: SQLite (Embedded)
|
- **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
|
## 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.
|
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
|
### API Endpoints
|
||||||
- `GET /api/projects`: List projects
|
- `GET /api/projects`: List projects
|
||||||
- `POST /api/projects`: Create a project
|
- `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
|
- `GET /api/activity`: Get recent activity
|
||||||
- `WS /api/deployments/:id/logs/stream`: Stream logs
|
- `WS /api/deployments/:id/logs/stream`: Stream logs
|
||||||
|
|
||||||
@@ -26,3 +28,4 @@ This service handles:
|
|||||||
- `internal/models/`: GORM models.
|
- `internal/models/`: GORM models.
|
||||||
- `internal/builder/`: Nixpacks wrapper.
|
- `internal/builder/`: Nixpacks wrapper.
|
||||||
- `internal/deployer/`: Docker SDK wrapper.
|
- `internal/deployer/`: Docker SDK wrapper.
|
||||||
|
- `internal/ports/`: Port allocation logic.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func main() {
|
|||||||
handler.RegisterWebhookRoutes(r)
|
handler.RegisterWebhookRoutes(r)
|
||||||
handler.RegisterSystemRoutes(r)
|
handler.RegisterSystemRoutes(r)
|
||||||
handler.RegisterStorageRoutes(r)
|
handler.RegisterStorageRoutes(r)
|
||||||
|
handler.RegisterAdminRoutes(r)
|
||||||
|
|
||||||
log.Println("Starting Clickploy Backend on :8080")
|
log.Println("Starting Clickploy Backend on :8080")
|
||||||
if err := r.Run(":8080"); err != nil {
|
if err := r.Run(":8080"); err != nil {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ require (
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/leodido/go-urn v1.2.4 // 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-isatty v0.0.19 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/moby/term v0.5.2 // 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/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 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
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 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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=
|
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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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=
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
imageName, err := h.builder.Build(req.Repo, req.Name, req.GitToken, "", "", "", "", nil, os.Stdout)
|
imageName, _, err := h.builder.Build(req.Repo, req.Name, req.GitToken, "", "", "", "", nil, os.Stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Build failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Build failed: %v", err)})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"clickploy/internal/models"
|
"clickploy/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthRequest struct {
|
type AuthRequest struct {
|
||||||
@@ -42,11 +43,17 @@ func (h *Handler) register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
db.DB.Model(&models.User{}).Count(&count)
|
||||||
|
|
||||||
|
userID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
user := models.User{
|
user := models.User{
|
||||||
|
ID: userID,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
Password: hashed,
|
Password: hashed,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Avatar: "https://github.com/shadcn.png",
|
Avatar: "https://github.com/shadcn.png",
|
||||||
|
IsAdmin: count == 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if result := db.DB.Create(&user); result.Error != nil {
|
if result := db.DB.Create(&user); result.Error != nil {
|
||||||
@@ -101,7 +108,7 @@ func (h *Handler) updateProfile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,7 +137,7 @@ func (h *Handler) updatePassword(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"clickploy/internal/auth"
|
"clickploy/internal/auth"
|
||||||
|
"clickploy/internal/db"
|
||||||
|
"clickploy/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -31,3 +33,29 @@ func AuthMiddleware() gin.HandlerFunc {
|
|||||||
c.Next()
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"clickploy/internal/auth"
|
|
||||||
"clickploy/internal/db"
|
"clickploy/internal/db"
|
||||||
"clickploy/internal/models"
|
"clickploy/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,14 +40,8 @@ func (h *Handler) updateProjectEnv(c *gin.Context) {
|
|||||||
return
|
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
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,19 +74,15 @@ func (h *Handler) redeployProject(c *gin.Context) {
|
|||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
projectID := c.Param("id")
|
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
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
deployment := models.Deployment{
|
deployment := models.Deployment{
|
||||||
|
ID: depID,
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
Status: "building",
|
Status: "building",
|
||||||
Commit: "MANUAL",
|
Commit: "MANUAL",
|
||||||
@@ -110,7 +100,7 @@ func (h *Handler) redeployProject(c *gin.Context) {
|
|||||||
envMap[env.Key] = env.Value
|
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()
|
deployment.Logs = logBuffer.String()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,6 +110,11 @@ func (h *Handler) redeployProject(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update commit hash if we got one
|
||||||
|
if commitHash != "" {
|
||||||
|
deployment.Commit = commitHash
|
||||||
|
}
|
||||||
|
|
||||||
var envStrings []string
|
var envStrings []string
|
||||||
for _, env := range project.EnvVars {
|
for _, env := range project.EnvVars {
|
||||||
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
|
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
|
||||||
@@ -190,12 +185,17 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
envVarsModel = append(envVarsModel, models.EnvVar{Key: k, Value: v})
|
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{
|
project := models.Project{
|
||||||
|
ID: id,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
RepoURL: req.Repo,
|
RepoURL: req.Repo,
|
||||||
OwnerID: userID.(uint),
|
OwnerID: userID.(string),
|
||||||
Port: port,
|
Port: port,
|
||||||
WebhookSecret: webhookSecret,
|
WebhookSecret: webhookSecret,
|
||||||
GitToken: req.GitToken,
|
GitToken: req.GitToken,
|
||||||
@@ -211,7 +211,9 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
deployment := models.Deployment{
|
deployment := models.Deployment{
|
||||||
|
ID: depID,
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
Status: "building",
|
Status: "building",
|
||||||
Commit: "HEAD",
|
Commit: "HEAD",
|
||||||
@@ -224,7 +226,7 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
streamer := &StreamWriter{DeploymentID: deployment.ID}
|
streamer := &StreamWriter{DeploymentID: deployment.ID}
|
||||||
multi := io.MultiWriter(&logBuffer, streamer)
|
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()
|
deployment.Logs = logBuffer.String()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -234,6 +236,11 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update commit hash
|
||||||
|
if commitHash != "" {
|
||||||
|
deployment.Commit = commitHash
|
||||||
|
}
|
||||||
|
|
||||||
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, envStrings)
|
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, envStrings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deployment.Status = "failed"
|
deployment.Status = "failed"
|
||||||
@@ -266,16 +273,10 @@ func (h *Handler) getProject(c *gin.Context) {
|
|||||||
userID, _ := c.Get("userID")
|
userID, _ := c.Get("userID")
|
||||||
projectID := c.Param("id")
|
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
|
var project models.Project
|
||||||
if result := db.DB.Order("created_at desc").Preload("Deployments", func(db *gorm.DB) *gorm.DB {
|
if result := db.DB.Order("created_at desc").Preload("Deployments", func(db *gorm.DB) *gorm.DB {
|
||||||
return db.Order("deployments.created_at desc")
|
return db.Order("deployments.created_at desc")
|
||||||
}).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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (h *Handler) handleGetStorageStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleListDatabases(c *gin.Context) {
|
func (h *Handler) handleListDatabases(c *gin.Context) {
|
||||||
userId := c.GetUint("userID")
|
userId := c.GetString("userID")
|
||||||
var dbs []models.Database
|
var dbs []models.Database
|
||||||
if err := db.DB.Where("owner_id = ?", userId).Find(&dbs).Error; err != nil {
|
if err := db.DB.Where("owner_id = ?", userId).Find(&dbs).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list databases"})
|
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) {
|
func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
||||||
userId := c.GetUint("userID")
|
userId := c.GetString("userID")
|
||||||
var req CreateDatabaseRequest
|
var req CreateDatabaseRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -81,7 +81,7 @@ func (h *Handler) handleCreateDatabase(c *gin.Context) {
|
|||||||
dataDir := "./data/user_dbs"
|
dataDir := "./data/user_dbs"
|
||||||
os.MkdirAll(dataDir, 0755)
|
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)
|
file, err := os.Create(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database file"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database file"})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,14 +15,14 @@ var upgrader = websocket.Upgrader{
|
|||||||
|
|
||||||
type LogHub struct {
|
type LogHub struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
streams map[uint][]chan []byte
|
streams map[string][]chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
var Hub = &LogHub{
|
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()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
if sinks, ok := h.streams[deploymentID]; ok {
|
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()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
ch := make(chan []byte, 256)
|
ch := make(chan []byte, 256)
|
||||||
@@ -44,7 +43,7 @@ func (h *LogHub) Subscribe(deploymentID uint) chan []byte {
|
|||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *LogHub) Unsubscribe(deploymentID uint, ch chan []byte) {
|
func (h *LogHub) Unsubscribe(deploymentID string, ch chan []byte) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
if sinks, ok := h.streams[deploymentID]; ok {
|
if sinks, ok := h.streams[deploymentID]; ok {
|
||||||
@@ -62,7 +61,7 @@ func (h *LogHub) Unsubscribe(deploymentID uint, ch chan []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StreamWriter struct {
|
type StreamWriter struct {
|
||||||
DeploymentID uint
|
DeploymentID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *StreamWriter) Write(p []byte) (n int, err error) {
|
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) {
|
func (h *Handler) streamDeploymentLogs(c *gin.Context) {
|
||||||
deploymentIDStr := c.Param("id")
|
deploymentID := c.Param("id")
|
||||||
deploymentID, err := strconv.ParseUint(deploymentIDStr, 10, 64)
|
if deploymentID == "" {
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,8 +84,8 @@ func (h *Handler) streamDeploymentLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
logChan := Hub.Subscribe(uint(deploymentID))
|
logChan := Hub.Subscribe(deploymentID)
|
||||||
defer Hub.Unsubscribe(uint(deploymentID), logChan)
|
defer Hub.Unsubscribe(deploymentID, logChan)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -5,38 +5,41 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"clickploy/internal/db"
|
"clickploy/internal/db"
|
||||||
"clickploy/internal/models"
|
"clickploy/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) RegisterWebhookRoutes(r *gin.Engine) {
|
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) {
|
func (h *Handler) handleWebhook(c *gin.Context) {
|
||||||
projectIDHex := c.Query("project_id")
|
projectID := c.Param("projectID")
|
||||||
if projectIDHex == "" {
|
webhookSecret := c.Param("webhookID")
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project_id required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pid, err := strconv.ParseUint(projectIDHex, 10, 64)
|
if projectID == "" || webhookSecret == "" {
|
||||||
if err != nil {
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook url"})
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var project models.Project
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if project.WebhookSecret != webhookSecret {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Webhook Secret"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10)
|
||||||
deployment := models.Deployment{
|
deployment := models.Deployment{
|
||||||
|
ID: depID,
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
Status: "building",
|
Status: "building",
|
||||||
Commit: "WEBHOOK",
|
Commit: "WEBHOOK",
|
||||||
@@ -57,7 +60,7 @@ func (h *Handler) handleWebhook(c *gin.Context) {
|
|||||||
envMap[env.Key] = env.Value
|
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()
|
deployment.Logs = logBuffer.String()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deployment.Status = "failed"
|
deployment.Status = "failed"
|
||||||
@@ -66,6 +69,10 @@ func (h *Handler) handleWebhook(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if commitHash != "" {
|
||||||
|
deployment.Commit = commitHash
|
||||||
|
}
|
||||||
|
|
||||||
var envStrings []string
|
var envStrings []string
|
||||||
for _, env := range project.EnvVars {
|
for _, env := range project.EnvVars {
|
||||||
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
|
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
var SecretKey = []byte("super-secret-key-change-me")
|
var SecretKey = []byte("super-secret-key-change-me")
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID uint `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ func CheckPassword(password, hash string) bool {
|
|||||||
return err == nil
|
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)
|
expirationTime := time.Now().Add(24 * time.Hour)
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
|||||||
@@ -16,20 +16,20 @@ func NewBuilder() *Builder {
|
|||||||
return &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)
|
workDir := filepath.Join("/tmp", "paas-builds", appName)
|
||||||
if err := os.RemoveAll(workDir); err != nil {
|
if err := os.RemoveAll(workDir); err != nil {
|
||||||
return "", fmt.Errorf("failed to clean work dir: %w", err)
|
return "", "", fmt.Errorf("failed to clean work dir: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create work dir: %w", err)
|
return "", "", fmt.Errorf("failed to create work dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cloneURL := repoURL
|
cloneURL := repoURL
|
||||||
if gitToken != "" {
|
if gitToken != "" {
|
||||||
u, err := url.Parse(repoURL)
|
u, err := url.Parse(repoURL)
|
||||||
if err != nil {
|
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)
|
u.User = url.UserPassword("oauth2", gitToken)
|
||||||
cloneURL = u.String()
|
cloneURL = u.String()
|
||||||
@@ -41,7 +41,19 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC
|
|||||||
cloneCmd.Stdout = logWriter
|
cloneCmd.Stdout = logWriter
|
||||||
cloneCmd.Stderr = logWriter
|
cloneCmd.Stderr = logWriter
|
||||||
if err := cloneCmd.Run(); err != nil {
|
if err := cloneCmd.Run(); err != nil {
|
||||||
return "", fmt.Errorf("git clone failed: %w", err)
|
return "", "", fmt.Errorf("git clone failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "" {
|
if runtime == "" {
|
||||||
@@ -106,7 +118,7 @@ cmd = "%s"
|
|||||||
if _, err := os.Stat(filepath.Join(workDir, "package.json")); err == nil {
|
if _, err := os.Stat(filepath.Join(workDir, "package.json")); err == nil {
|
||||||
configPath := filepath.Join(workDir, "nixpacks.toml")
|
configPath := filepath.Join(workDir, "nixpacks.toml")
|
||||||
if err := os.WriteFile(configPath, []byte(nixpacksConfig), 0644); err != nil {
|
if err := os.WriteFile(configPath, []byte(nixpacksConfig), 0644); err != nil {
|
||||||
return "", fmt.Errorf("failed to write nixpacks.toml: %w", err)
|
return "", "", fmt.Errorf("failed to write nixpacks.toml: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,10 +141,10 @@ cmd = "%s"
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err := nixCmd.Run(); err != nil {
|
if err := nixCmd.Run(); err != nil {
|
||||||
return "", fmt.Errorf("nixpacks build failed: %w", err)
|
return "", "", fmt.Errorf("nixpacks build failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(logWriter, "\n>>> Build successful!\n")
|
fmt.Fprintf(logWriter, "\n>>> Build successful!\n")
|
||||||
|
|
||||||
return imageName, nil
|
return imageName, commitHash, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,61 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
gorm.Model
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Password string `json:"-"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Name string `json:"name"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||||
Avatar string `json:"avatar"`
|
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||||
Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"`
|
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 {
|
type Project struct {
|
||||||
gorm.Model
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"uniqueIndex" json:"name"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
RepoURL string `json:"repo_url"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
OwnerID uint `json:"owner_id"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||||
Port int `json:"port"`
|
Name string `gorm:"uniqueIndex" json:"name"`
|
||||||
WebhookSecret string `json:"webhook_secret"`
|
RepoURL string `json:"repo_url"`
|
||||||
GitToken string `json:"-"`
|
OwnerID string `json:"owner_id"`
|
||||||
BuildCommand string `json:"build_command"`
|
Port int `json:"port"`
|
||||||
StartCommand string `json:"start_command"`
|
WebhookSecret string `json:"webhook_secret"`
|
||||||
InstallCommand string `json:"install_command"`
|
GitToken string `json:"-"`
|
||||||
Runtime string `json:"runtime"`
|
BuildCommand string `json:"build_command"`
|
||||||
Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"`
|
StartCommand string `json:"start_command"`
|
||||||
EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"`
|
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 {
|
type EnvVar struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
ProjectID uint `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Deployment struct {
|
type Deployment struct {
|
||||||
gorm.Model
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
ProjectID uint `json:"project_id"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Project Project `json:"project" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Status string `json:"status"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||||
Commit string `json:"commit"`
|
ProjectID string `json:"project_id"`
|
||||||
Logs string `json:"logs"`
|
Project Project `json:"project" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||||
URL string `json:"url"`
|
Status string `json:"status"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
Logs string `json:"logs"`
|
||||||
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
@@ -51,6 +63,6 @@ type Database struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
OwnerID uint `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
SizeMB float64 `json:"size_mb"`
|
SizeMB float64 `json:"size_mb"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ A modern, responsive dashboard to manage your applications, monitor deployments,
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
- **Project List**: View all deployed applications.
|
- **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.
|
- **Redeploy**: Trigger manual redeployments.
|
||||||
- **Environment Variables**: Manage runtime configuration.
|
- **Environment Variables**: Manage runtime configuration.
|
||||||
- **Responsive Design**: Works on desktop and mobile.
|
- **Responsive Design**: Works on desktop and mobile.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.10.1",
|
"@internationalized/date": "^3.11.0",
|
||||||
"@lucide/svelte": "^0.561.0",
|
"@lucide/svelte": "^0.561.0",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"svelte-sonner": "^1.0.7"
|
"svelte-sonner": "^1.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,23 @@ export interface EnvVar {
|
|||||||
value: string;
|
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 {
|
export interface Project {
|
||||||
ID: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
repo_url: string;
|
repo_url: string;
|
||||||
port: number;
|
port: number;
|
||||||
deployments: any[];
|
deployments: Deployment[];
|
||||||
env_vars: EnvVar[];
|
env_vars: EnvVar[];
|
||||||
webhook_secret: string;
|
webhook_secret: string;
|
||||||
git_token?: string;
|
git_token?: string;
|
||||||
@@ -228,3 +239,33 @@ export async function createDatabase(name: string, type: string = "sqlite") {
|
|||||||
return null;
|
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';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
ID: number;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
is_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedUser = browser ? localStorage.getItem('user') : null;
|
const storedUser = browser ? localStorage.getItem('user') : null;
|
||||||
|
|||||||
@@ -42,15 +42,13 @@
|
|||||||
class="flex flex-wrap justify-center gap-4 text-sm text-muted-foreground"
|
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">Home</a>
|
||||||
<a href="/" class="hover:text-foreground transition-colors">Docs</a>
|
<a href="/docs" class="hover:text-foreground transition-colors">Docs</a>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/SirBlobby/Clickploy"
|
href="https://github.com/SirBlobby/Clickploy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="hover:text-foreground transition-colors">GitHub</a
|
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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,6 +61,11 @@
|
|||||||
<span class={isNormal ? "text-blue-500" : "text-red-500"}>
|
<span class={isNormal ? "text-blue-500" : "text-red-500"}>
|
||||||
{isNormal ? "All systems normal." : status}
|
{isNormal ? "All systems normal." : status}
|
||||||
</span>
|
</span>
|
||||||
|
{#if version}
|
||||||
|
<span class="text-muted-foreground ml-2">
|
||||||
|
{version}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
Rocket,
|
Rocket,
|
||||||
Network,
|
Network,
|
||||||
Database,
|
Database,
|
||||||
|
Shield,
|
||||||
|
Book,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import * as Sheet from "$lib/components/ui/sheet";
|
import * as Sheet from "$lib/components/ui/sheet";
|
||||||
@@ -35,48 +37,83 @@
|
|||||||
|
|
||||||
{#if $user}
|
{#if $user}
|
||||||
<nav class="hidden md:flex items-center gap-2">
|
<nav class="hidden md:flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" href="/" class={isActive("/")}>
|
<Button
|
||||||
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
href="/deployments"
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
href="/network"
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
href="/activity"
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
href="/storage"
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
href="/settings"
|
href="/docs"
|
||||||
class={isActive("/settings")}
|
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>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -84,6 +121,26 @@
|
|||||||
|
|
||||||
{#if $user}
|
{#if $user}
|
||||||
<div class="flex items-center gap-4">
|
<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="hidden md:flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="h-8 w-8 rounded-full bg-linear-to-tr from-primary to-purple-500"
|
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
|
<Database class="h-5 w-5" /> Storage
|
||||||
</a>
|
</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
|
<a
|
||||||
href="/settings"
|
href="/settings"
|
||||||
class="flex items-center gap-2 py-2 text-lg font-medium"
|
class="flex items-center gap-2 py-2 text-lg font-medium"
|
||||||
@@ -154,6 +218,15 @@
|
|||||||
>
|
>
|
||||||
<Settings class="h-5 w-5" /> Settings
|
<Settings class="h-5 w-5" /> Settings
|
||||||
</a>
|
</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="border-t my-2"></div>
|
||||||
<div class="flex items-center gap-2 py-2">
|
<div class="flex items-center gap-2 py-2">
|
||||||
<div
|
<div
|
||||||
@@ -175,6 +248,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" href="/docs">Docs</Button>
|
||||||
<Button variant="ghost" size="sm" href="/login">Login</Button>
|
<Button variant="ghost" size="sm" href="/login">Login</Button>
|
||||||
<Button size="sm" href="/register">Get Started</Button>
|
<Button size="sm" href="/register">Get Started</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export {
|
|||||||
Root,
|
Root,
|
||||||
Description,
|
Description,
|
||||||
Title,
|
Title,
|
||||||
//
|
|
||||||
Root as Alert,
|
Root as Alert,
|
||||||
Description as AlertDescription,
|
Description as AlertDescription,
|
||||||
Title as AlertTitle,
|
Title as AlertTitle,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Root, {
|
|||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
type ButtonProps as Props,
|
type ButtonProps as Props,
|
||||||
//
|
|
||||||
Root as Button,
|
Root as Button,
|
||||||
buttonVariants,
|
buttonVariants,
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export {
|
|||||||
Header,
|
Header,
|
||||||
Title,
|
Title,
|
||||||
Action,
|
Action,
|
||||||
//
|
|
||||||
Root as Card,
|
Root as Card,
|
||||||
Content as CardContent,
|
Content as CardContent,
|
||||||
Description as CardDescription,
|
Description as CardDescription,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export {
|
|||||||
Root,
|
Root,
|
||||||
Content,
|
Content,
|
||||||
Trigger,
|
Trigger,
|
||||||
//
|
|
||||||
Root as Collapsible,
|
Root as Collapsible,
|
||||||
Content as CollapsibleContent,
|
Content as CollapsibleContent,
|
||||||
Trigger as CollapsibleTrigger,
|
Trigger as CollapsibleTrigger,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export {
|
|||||||
Content,
|
Content,
|
||||||
Description,
|
Description,
|
||||||
Close,
|
Close,
|
||||||
//
|
|
||||||
Root as Dialog,
|
Root as Dialog,
|
||||||
Title as DialogTitle,
|
Title as DialogTitle,
|
||||||
Portal as DialogPortal,
|
Portal as DialogPortal,
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ import Root from "./input.svelte";
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
|
||||||
Root as Input,
|
Root as Input,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ import Root from "./label.svelte";
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
|
||||||
Root as Label,
|
Root as Label,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ import Root from "./progress.svelte";
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
|
||||||
Root as Progress,
|
Root as Progress,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export {
|
|||||||
ScrollUpButton,
|
ScrollUpButton,
|
||||||
GroupHeading,
|
GroupHeading,
|
||||||
Portal,
|
Portal,
|
||||||
//
|
|
||||||
Root as Select,
|
Root as Select,
|
||||||
Group as SelectGroup,
|
Group as SelectGroup,
|
||||||
Label as SelectLabel,
|
Label as SelectLabel,
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ import Root from "./separator.svelte";
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
|
||||||
Root as Separator,
|
Root as Separator,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export {
|
|||||||
Footer,
|
Footer,
|
||||||
Title,
|
Title,
|
||||||
Description,
|
Description,
|
||||||
//
|
|
||||||
Root as Sheet,
|
Root as Sheet,
|
||||||
Close as SheetClose,
|
Close as SheetClose,
|
||||||
Trigger as SheetTrigger,
|
Trigger as SheetTrigger,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export {
|
|||||||
Head,
|
Head,
|
||||||
Header,
|
Header,
|
||||||
Row,
|
Row,
|
||||||
//
|
|
||||||
Root as Table,
|
Root as Table,
|
||||||
Body as TableBody,
|
Body as TableBody,
|
||||||
Caption as TableCaption,
|
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,
|
Settings,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Upload,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import * as Collapsible from "$lib/components/ui/collapsible";
|
import * as Collapsible from "$lib/components/ui/collapsible";
|
||||||
import * as Select from "$lib/components/ui/select";
|
import * as Select from "$lib/components/ui/select";
|
||||||
@@ -59,6 +60,48 @@
|
|||||||
envVars = envVars.filter((_, i) => i !== index);
|
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() {
|
async function handleDeploy() {
|
||||||
if (!repo || !name) return;
|
if (!repo || !name) return;
|
||||||
deploying = true;
|
deploying = true;
|
||||||
@@ -108,21 +151,99 @@
|
|||||||
<div class="container mx-auto py-10 px-4">
|
<div class="container mx-auto py-10 px-4">
|
||||||
{#if !$user}
|
{#if !$user}
|
||||||
<div
|
<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
|
<div
|
||||||
class="bg-linear-to-r from-blue-400 to-purple-600 bg-clip-text text-transparent"
|
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)]"
|
||||||
>One Click</span
|
>
|
||||||
>.
|
<Terminal class="w-12 h-12 text-primary" />
|
||||||
<p class="max-w-[600px] text-muted-foreground text-xl">
|
</div>
|
||||||
The simplified PaaS for your personal projects. Push, build, and scale
|
|
||||||
without the complexity.
|
<div class="space-y-4 max-w-2xl">
|
||||||
</p>
|
<h1 class="text-4xl md:text-6xl font-extrabold tracking-tight">
|
||||||
<div class="flex gap-4">
|
Deploy with Clickploy
|
||||||
<Button href="/login" size="lg">Get Started</Button>
|
</h1>
|
||||||
<Button href="https://github.com/clickploy" variant="outline" size="lg">
|
<p class="text-xl text-muted-foreground leading-relaxed">
|
||||||
<Github class="mr-2 h-4 w-4" /> GitHub
|
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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -165,14 +286,14 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
readonly
|
readonly
|
||||||
value={`http://localhost:8080/webhooks/trigger?project_id=${createdProject.ID}`}
|
value={`http://localhost:8080/webhooks/trigger?project_id=${createdProject.id}`}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`http://localhost:8080/webhooks/trigger?project_id=${createdProject.ID}`,
|
`http://localhost:8080/webhooks/trigger?project_id=${createdProject.id}`,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
@@ -341,6 +462,24 @@
|
|||||||
>
|
>
|
||||||
<Plus class="mr-2 h-4 w-4" /> Add Variable
|
<Plus class="mr-2 h-4 w-4" /> Add Variable
|
||||||
</Button>
|
</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.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,16 +530,16 @@
|
|||||||
<Card
|
<Card
|
||||||
class="group hover:shadow-lg transition-all duration-300 border-muted/60 hover:border-primary/50 cursor-pointer overflow-hidden relative"
|
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">
|
<CardHeader class="pb-3">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<CardTitle class="text-xl flex items-center gap-2">
|
<CardTitle class="text-xl flex items-center gap-2">
|
||||||
{project.name}
|
{project.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription class="flex items-center gap-1">
|
<CardDescription class="flex items-center gap-1" >
|
||||||
<Github class="h-3 w-3" />
|
<Github class="h-3 w-3" />
|
||||||
{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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -464,7 +603,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
|
class="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
|
||||||
href={`/projects/${project.ID}`}
|
href={`/projects/${project.id}`}
|
||||||
>
|
>
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -60,10 +60,10 @@
|
|||||||
<div
|
<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"
|
||||||
>
|
>
|
||||||
<div class="col-span-3">Project</div>
|
<div class="col-span-6 md:col-span-3">Project</div>
|
||||||
<div class="col-span-4">Commit</div>
|
<div class="hidden md:block col-span-4">Commit</div>
|
||||||
<div class="col-span-2">Status</div>
|
<div class="col-span-4 md:col-span-2">Status</div>
|
||||||
<div class="col-span-3 text-right">Time</div>
|
<div class="col-span-2 md:col-span-3 text-right">Time</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divide-y divide-border/40">
|
<div class="divide-y divide-border/40">
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
<div
|
<div
|
||||||
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-colors text-sm group"
|
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
|
<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"
|
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>
|
||||||
|
|
||||||
<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" />
|
<GitCommit class="h-3.5 w-3.5 shrink-0" />
|
||||||
<span
|
<span
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2">
|
<div class="col-span-4 md:col-span-2">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border
|
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border
|
||||||
{activity.status === 'live'
|
{activity.status === 'live'
|
||||||
@@ -122,9 +122,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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, {
|
{new Date(activity.CreatedAt).toLocaleString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
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>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Project</TableHead>
|
<TableHead>Project</TableHead>
|
||||||
<TableHead>Commit</TableHead>
|
<TableHead class="hidden md:table-cell">Commit</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead class="hidden md:table-cell">Created</TableHead>
|
||||||
<TableHead class="text-right">Actions</TableHead>
|
<TableHead class="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<TableCell class="font-medium">
|
<TableCell class="font-medium">
|
||||||
{#if deploy.project}
|
{#if deploy.project}
|
||||||
<a
|
<a
|
||||||
href={`/projects/${deploy.project.ID}`}
|
href={`/projects/${deploy.project.id}`}
|
||||||
class="hover:underline flex items-center gap-2"
|
class="hover:underline flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{deploy.project.name}
|
{deploy.project.name}
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
<span class="text-muted-foreground">Deleted Project</span>
|
<span class="text-muted-foreground">Deleted Project</span>
|
||||||
{/if}
|
{/if}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell class="hidden md:table-cell">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<GitCommit class="h-4 w-4 text-muted-foreground" />
|
<GitCommit class="h-4 w-4 text-muted-foreground" />
|
||||||
<span class="font-mono text-sm"
|
<span class="font-mono text-sm"
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
{deploy.status}
|
{deploy.status}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-muted-foreground">
|
<TableCell class="hidden md:table-cell text-muted-foreground">
|
||||||
{new Date(deploy.CreatedAt).toLocaleDateString()}
|
{new Date(deploy.CreatedAt).toLocaleDateString()}
|
||||||
{new Date(deploy.CreatedAt).toLocaleTimeString()}
|
{new Date(deploy.CreatedAt).toLocaleTimeString()}
|
||||||
</TableCell>
|
</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">
|
<div class="flex items-center gap-2">
|
||||||
<Server class="h-4 w-4 text-muted-foreground" />
|
<Server class="h-4 w-4 text-muted-foreground" />
|
||||||
<a
|
<a
|
||||||
href={`/projects/${project.ID}`}
|
href={`/projects/${project.id}`}
|
||||||
class="hover:underline"
|
class="hover:underline"
|
||||||
>
|
>
|
||||||
{project.name}
|
{project.name}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
let status = $derived(latestDeployment?.status || "unknown");
|
let status = $derived(latestDeployment?.status || "unknown");
|
||||||
|
|
||||||
let activeDeploymentLogs = $state("");
|
let activeDeploymentLogs = $state("");
|
||||||
let activeDeploymentId = $state<number | null>(null);
|
let activeDeploymentId = $state<string | null>(null);
|
||||||
let ws = $state<WebSocket | null>(null);
|
let ws = $state<WebSocket | null>(null);
|
||||||
let logContentRef = $state<HTMLDivElement | null>(null);
|
let logContentRef = $state<HTMLDivElement | null>(null);
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
async function handleRedeploy() {
|
async function handleRedeploy() {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
toast.info("Starting redeployment...");
|
toast.info("Starting redeployment...");
|
||||||
const success = await redeployProject(project.ID.toString());
|
const success = await redeployProject(project.id.toString());
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success("Redeployment started!");
|
toast.success("Redeployment started!");
|
||||||
setTimeout(loadProject, 1000);
|
setTimeout(loadProject, 1000);
|
||||||
@@ -71,16 +71,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectDeployment(deployment: any) {
|
function selectDeployment(deployment: any) {
|
||||||
if (activeDeploymentId === deployment.ID) return;
|
if (activeDeploymentId === deployment.id) return;
|
||||||
|
|
||||||
activeDeploymentId = deployment.ID;
|
activeDeploymentId = deployment.id;
|
||||||
activeDeploymentLogs = deployment.logs || "";
|
activeDeploymentLogs = deployment.logs || "";
|
||||||
userScrolled = false;
|
userScrolled = false;
|
||||||
autoScroll = true;
|
autoScroll = true;
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
|
|
||||||
if (deployment.status === "building") {
|
if (deployment.status === "building") {
|
||||||
connectWebSocket(deployment.ID);
|
connectWebSocket(deployment.id);
|
||||||
} else {
|
} else {
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket(deploymentId: number) {
|
function connectWebSocket(deploymentId: string) {
|
||||||
if (ws) ws.close();
|
if (ws) ws.close();
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
ws = new WebSocket(
|
ws = new WebSocket(
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{:else if project}
|
{: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="shrink-0 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<div
|
||||||
class="bg-card p-3 flex flex-col justify-center items-center relative overflow-hidden group"
|
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">
|
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-4 min-h-0">
|
||||||
<Card
|
<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">
|
<div class="flex items-center justify-between px-1 pb-2">
|
||||||
<h3 class="text-sm font-semibold text-muted-foreground">History</h3>
|
<h3 class="text-sm font-semibold text-muted-foreground">History</h3>
|
||||||
@@ -270,25 +270,29 @@
|
|||||||
{#each project.deployments as deployment}
|
{#each project.deployments as deployment}
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center justify-between p-2.5 rounded-md border text-left transition-all text-xs
|
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-primary/5 border-primary/20 shadow-sm'
|
||||||
: 'bg-card hover:bg-muted/50 border-input'}"
|
: 'bg-card hover:bg-muted/50 border-input'}"
|
||||||
onclick={() => selectDeployment(deployment)}
|
onclick={() => selectDeployment(deployment)}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-semibold">#{deployment.ID}</span>
|
<span class="font-semibold text-xs">#{deployment.id}</span>
|
||||||
<span
|
<span
|
||||||
class="font-mono text-[10px] text-muted-foreground bg-muted px-1 rounded flex items-center gap-1"
|
class="font-mono text-[10px] text-muted-foreground bg-muted px-1 rounded flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<GitCommit class="h-2 w-2" />
|
<GitCommit class="h-2 w-2" />
|
||||||
{deployment.commit
|
{deployment.commit === "HEAD"
|
||||||
? deployment.commit.substring(0, 7)
|
? "HEAD"
|
||||||
: "HEAD"}
|
: deployment.commit === "MANUAL"
|
||||||
|
? "Manual"
|
||||||
|
: deployment.commit === "WEBHOOK"
|
||||||
|
? "Webhook"
|
||||||
|
: deployment.commit.substring(0, 7)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] text-muted-foreground truncate">
|
<span class="text-[10px] text-muted-foreground truncate">
|
||||||
{new Date(deployment.CreatedAt).toLocaleString(undefined, {
|
{new Date(deployment.created_at).toLocaleString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -322,14 +326,15 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div
|
<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
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<Terminal class="h-3.5 w-3.5 text-zinc-400" />
|
<Terminal class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span class="text-xs font-mono text-zinc-300">
|
|
||||||
|
<span class="text-xs font-mono text-muted-foreground">
|
||||||
{#if activeDeploymentId}
|
{#if activeDeploymentId}
|
||||||
build-log-{activeDeploymentId}.log
|
build-log-{activeDeploymentId}.log
|
||||||
{:else}
|
{:else}
|
||||||
@@ -365,7 +370,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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}
|
onclick={copyLogs}
|
||||||
title="Copy Logs"
|
title="Copy Logs"
|
||||||
>
|
>
|
||||||
@@ -381,14 +386,14 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={logContentRef}
|
bind:this={logContentRef}
|
||||||
onscroll={handleScroll}
|
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}
|
{#if activeDeploymentLogs}
|
||||||
<pre
|
<pre
|
||||||
class="whitespace-pre-wrap break-all">{activeDeploymentLogs}</pre>
|
class="whitespace-pre-wrap break-all">{activeDeploymentLogs}</pre>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<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>
|
<p>Select a deployment to view logs</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
(d) =>
|
(d) =>
|
||||||
d.commit.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
d.commit.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
d.status.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">
|
<CardContent class="p-0">
|
||||||
{#if project.deployments?.length}
|
{#if project.deployments?.length}
|
||||||
<div
|
<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-4 md:col-span-1">ID</div>
|
||||||
<div class="col-span-2">Status</div>
|
<div class="col-span-4 md:col-span-2">Status</div>
|
||||||
<div class="col-span-5">Commit</div>
|
<div class="hidden md:block col-span-5">Commit</div>
|
||||||
<div class="col-span-3">Date</div>
|
<div class="hidden md:block col-span-3">Date</div>
|
||||||
<div class="col-span-1 text-right">Actions</div>
|
<div class="col-span-4 md:col-span-1">Actions</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divide-y divide-border/40">
|
<div class="divide-y divide-border/40">
|
||||||
{#each filteredDeployments as deployment}
|
{#each filteredDeployments as deployment}
|
||||||
{@const StatusIcon = getStatusIcon(deployment.status)}
|
{@const StatusIcon = getStatusIcon(deployment.status)}
|
||||||
<div
|
<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">
|
<div class="col-span-4 md:col-span-1 font-mono text-xs text-muted-foreground">
|
||||||
#{deployment.ID}
|
#{deployment.id}
|
||||||
</div>
|
</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
|
<StatusIcon
|
||||||
class="h-4 w-4 {getStatusColor(
|
class="h-4 w-4 {getStatusColor(
|
||||||
deployment.status,
|
deployment.status,
|
||||||
@@ -125,15 +125,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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" />
|
<GitCommit class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span
|
<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 === "HEAD"
|
||||||
? deployment.commit.substring(0, 7)
|
? "HEAD"
|
||||||
: "HEAD"}
|
: deployment.commit === "MANUAL"
|
||||||
|
? "MANUAL"
|
||||||
|
: deployment.commit === "WEBHOOK"
|
||||||
|
? "WEBHOOK"
|
||||||
|
: deployment.commit.substring(0, 7)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="text-muted-foreground truncate hidden md:inline-block max-w-[200px]"
|
class="text-muted-foreground truncate hidden md:inline-block max-w-[200px]"
|
||||||
@@ -142,16 +146,16 @@
|
|||||||
? "Manual Redeploy"
|
? "Manual Redeploy"
|
||||||
: deployment.commit === "WEBHOOK"
|
: deployment.commit === "WEBHOOK"
|
||||||
? "Webhook Trigger"
|
? "Webhook Trigger"
|
||||||
: "Git Push"}
|
: "Git Push"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-3 text-xs text-muted-foreground">
|
<div class="hidden md:block col-span-3 text-xs text-muted-foreground">
|
||||||
{new Date(deployment.CreatedAt).toLocaleString()}
|
{new Date(deployment.created_at).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"}
|
{#if deployment.status === "live"}
|
||||||
<Button
|
<Button
|
||||||
@@ -169,7 +173,7 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
href={`/projects/${project.ID}?deployment=${deployment.ID}`}
|
href={`/projects/${project.id}?deployment=${deployment.id}`}
|
||||||
title="View Logs"
|
title="View Logs"
|
||||||
>
|
>
|
||||||
<Terminal class="h-3.5 w-3.5" />
|
<Terminal class="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Plus,
|
Plus,
|
||||||
Copy,
|
Copy,
|
||||||
|
Upload,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
@@ -90,12 +91,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
const success = await updateProjectEnv(project.ID.toString(), envMap);
|
const success = await updateProjectEnv(project.id.toString(), envMap);
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success("Environment variables updated successfully");
|
toast.success("Environment variables updated successfully");
|
||||||
const res = await getProject(project.ID.toString());
|
const res = await getProject(project.id.toString());
|
||||||
if (res) {
|
if (res) {
|
||||||
project = res;
|
project = res;
|
||||||
initEnvVars();
|
initEnvVars();
|
||||||
@@ -107,10 +108,46 @@
|
|||||||
|
|
||||||
function copyWebhook() {
|
function copyWebhook() {
|
||||||
if (!project) return;
|
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);
|
navigator.clipboard.writeText(displayUrl);
|
||||||
toast.success("Webhook URL copied");
|
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>
|
</script>
|
||||||
|
|
||||||
{#if loading && !project}
|
{#if loading && !project}
|
||||||
@@ -166,14 +203,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<Button
|
<div class="flex gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
class="w-full h-11 border-dashed border-border/60 hover:bg-muted/50"
|
variant="outline"
|
||||||
onclick={addEnvVar}
|
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
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
</Button>
|
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>
|
</CardContent>
|
||||||
<CardFooter class="border-t px-4 flex justify-end">
|
<CardFooter class="border-t px-4 flex justify-end">
|
||||||
<Button onclick={saveEnvVars} disabled={!isDirty || loading} size="sm">
|
<Button onclick={saveEnvVars} disabled={!isDirty || loading} size="sm">
|
||||||
@@ -199,7 +254,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
readonly
|
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"
|
class="bg-muted font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="icon" onclick={copyWebhook}>
|
<Button variant="outline" size="icon" onclick={copyWebhook}>
|
||||||
@@ -207,15 +262,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>New Password</Label>
|
<Label>New Password</Label>
|
||||||
type="password" bind:value={newPassword}
|
<Input
|
||||||
required minlength={6}
|
type="password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
required
|
||||||
|
minlength={6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" variant="secondary" disabled={loading}
|
<Button type="submit" variant="secondary" disabled={loading}
|
||||||
|
|||||||
Reference in New Issue
Block a user